diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/website/static/src/js/menu | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/website/static/src/js/menu')
| -rw-r--r-- | addons/website/static/src/js/menu/content.js | 1129 | ||||
| -rw-r--r-- | addons/website/static/src/js/menu/customize.js | 219 | ||||
| -rw-r--r-- | addons/website/static/src/js/menu/debug_manager.js | 21 | ||||
| -rw-r--r-- | addons/website/static/src/js/menu/edit.js | 256 | ||||
| -rw-r--r-- | addons/website/static/src/js/menu/mobile_view.js | 68 | ||||
| -rw-r--r-- | addons/website/static/src/js/menu/navbar.js | 292 | ||||
| -rw-r--r-- | addons/website/static/src/js/menu/new_content.js | 350 | ||||
| -rw-r--r-- | addons/website/static/src/js/menu/seo.js | 902 | ||||
| -rw-r--r-- | addons/website/static/src/js/menu/translate.js | 88 |
9 files changed, 3325 insertions, 0 deletions
diff --git a/addons/website/static/src/js/menu/content.js b/addons/website/static/src/js/menu/content.js new file mode 100644 index 00000000..d2dff980 --- /dev/null +++ b/addons/website/static/src/js/menu/content.js @@ -0,0 +1,1129 @@ +odoo.define('website.contentMenu', function (require) { +'use strict'; + +var Class = require('web.Class'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var time = require('web.time'); +var weWidgets = require('wysiwyg.widgets'); +var websiteNavbarData = require('website.navbar'); +var websiteRootData = require('website.root'); +var Widget = require('web.Widget'); + +var _t = core._t; +var qweb = core.qweb; + +var PagePropertiesDialog = weWidgets.Dialog.extend({ + template: 'website.pagesMenu.page_info', + xmlDependencies: weWidgets.Dialog.prototype.xmlDependencies.concat( + ['/website/static/src/xml/website.pageProperties.xml'] + ), + events: _.extend({}, weWidgets.Dialog.prototype.events, { + 'keyup input#page_name': '_onNameChanged', + 'keyup input#page_url': '_onUrlChanged', + 'change input#create_redirect': '_onCreateRedirectChanged', + 'click input#visibility_password': '_onPasswordClicked', + 'change input#visibility_password': '_onPasswordChanged', + 'change select#visibility': '_onVisibilityChanged', + 'error.datetimepicker': '_onDateTimePickerError', + }), + + /** + * @constructor + * @override + */ + init: function (parent, page_id, options) { + var self = this; + var serverUrl = window.location.origin + '/'; + var length_url = serverUrl.length; + var serverUrlTrunc = serverUrl; + if (length_url > 30) { + serverUrlTrunc = serverUrl.slice(0,14) + '..' + serverUrl.slice(-14); + } + this.serverUrl = serverUrl; + this.serverUrlTrunc = serverUrlTrunc; + this.current_page_url = window.location.pathname; + this.page_id = page_id; + + var buttons = [ + {text: _t("Save"), classes: 'btn-primary', click: this.save}, + {text: _t("Discard"), classes: 'mr-auto', close: true}, + ]; + if (options.fromPageManagement) { + buttons.push({ + text: _t("Go To Page"), + icon: 'fa-globe', + classes: 'btn-link', + click: function (e) { + window.location.href = '/' + self.page.url; + }, + }); + } + buttons.push({ + text: _t("Duplicate Page"), + icon: 'fa-clone', + classes: 'btn-link', + click: function (e) { + // modal('hide') will break the rpc, so hide manually + this.$el.closest('.modal').addClass('d-none'); + _clonePage.call(this, self.page_id); + }, + }); + buttons.push({ + text: _t("Delete Page"), + icon: 'fa-trash', + classes: 'btn-link', + click: function (e) { + _deletePage.call(this, self.page_id, options.fromPageManagement); + }, + }); + this._super(parent, _.extend({}, { + title: _t("Page Properties"), + size: 'medium', + buttons: buttons, + }, options || {})); + }, + /** + * @override + */ + willStart: function () { + var defs = [this._super.apply(this, arguments)]; + var self = this; + + defs.push(this._rpc({ + model: 'website.page', + method: 'get_page_properties', + args: [this.page_id], + }).then(function (page) { + page.url = _.str.startsWith(page.url, '/') ? page.url.substring(1) : page.url; + page.hasSingleGroup = page.group_id !== undefined; + self.page = page; + })); + + return Promise.all(defs); + }, + /** + * @override + */ + start: function () { + var self = this; + + var defs = [this._super.apply(this, arguments)]; + + this.$('.ask_for_redirect').addClass('d-none'); + this.$('.redirect_type').addClass('d-none'); + this.$('.warn_about_call').addClass('d-none'); + if (this.page.visibility !== 'password') { + this.$('.show_visibility_password').addClass('d-none'); + } + if (this.page.visibility !== 'restricted_group') { + this.$('.show_group_id').addClass('d-none'); + } + this.autocompleteWithGroups(this.$('#group_id')); + + defs.push(this._getPageDependencies(this.page_id) + .then(function (dependencies) { + var dep_text = []; + _.each(dependencies, function (value, index) { + if (value.length > 0) { + dep_text.push(value.length + ' ' + index.toLowerCase()); + } + }); + dep_text = dep_text.join(', '); + self.$('#dependencies_redirect').html(qweb.render('website.show_page_dependencies', { dependencies: dependencies, dep_text: dep_text })); + self.$('#dependencies_redirect [data-toggle="popover"]').popover({ + container: 'body', + }); + })); + + defs.push(this._getSupportedMimetype() + .then(function (mimetypes) { + self.supportedMimetype = mimetypes; + })); + + defs.push(this._getPageKeyDependencies(this.page_id) + .then(function (dependencies) { + var dep_text = []; + _.each(dependencies, function (value, index) { + if (value.length > 0) { + dep_text.push(value.length + ' ' + index.toLowerCase()); + } + }); + dep_text = dep_text.join(', '); + self.$('.warn_about_call').html(qweb.render('website.show_page_key_dependencies', {dependencies: dependencies, dep_text: dep_text})); + self.$('.warn_about_call [data-toggle="popover"]').popover({ + container: 'body', + }); + })); + + defs.push(this._rpc({model: 'res.users', + method: 'has_group', + args: ['website.group_multi_website']}) + .then(function (has_group) { + if (!has_group) { + self.$('#website_restriction').addClass('hidden'); + } + })); + + var datepickersOptions = { + minDate: moment({ y: 1000 }), + maxDate: moment().add(200, 'y'), + calendarWeeks: true, + icons : { + time: 'fa fa-clock-o', + date: 'fa fa-calendar', + next: 'fa fa-chevron-right', + previous: 'fa fa-chevron-left', + up: 'fa fa-chevron-up', + down: 'fa fa-chevron-down', + }, + locale : moment.locale(), + format : time.getLangDatetimeFormat(), + widgetPositioning : { + horizontal: 'auto', + vertical: 'top', + }, + widgetParent: 'body', + }; + if (this.page.date_publish) { + datepickersOptions.defaultDate = time.str_to_datetime(this.page.date_publish); + } + this.$('#date_publish_container').datetimepicker(datepickersOptions); + return Promise.all(defs); + }, + /** + * @override + */ + destroy: function () { + $('.popover').popover('hide'); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + save: function (data) { + var self = this; + var context; + this.trigger_up('context_get', { + callback: function (ctx) { + context = ctx; + }, + }); + var url = this.$('#page_url').val(); + + var $datePublish = this.$("#date_publish"); + $datePublish.closest(".form-group").removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid'); + var datePublish = $datePublish.val(); + if (datePublish !== "") { + datePublish = this._parse_date(datePublish); + if (!datePublish) { + $datePublish.closest(".form-group").addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid'); + return; + } + } + var params = { + id: this.page.id, + name: this.$('#page_name').val(), + // Replace duplicate following '/' by only one '/' + url: url.replace(/\/{2,}/g, '/'), + is_menu: this.$('#is_menu').prop('checked'), + is_homepage: this.$('#is_homepage').prop('checked'), + website_published: this.$('#is_published').prop('checked'), + create_redirect: this.$('#create_redirect').prop('checked'), + redirect_type: this.$('#redirect_type').val(), + website_indexed: this.$('#is_indexed').prop('checked'), + visibility: this.$('#visibility').val(), + date_publish: datePublish, + }; + if (this.page.hasSingleGroup && this.$('#visibility').val() === 'restricted_group') { + params['group_id'] = this.$('#group_id').data('group-id'); + } + if (this.$('#visibility').val() === 'password') { + var field_pwd = $('#visibility_password'); + if (!field_pwd.get(0).reportValidity()) { + return; + } + if (field_pwd.data('dirty')) { + params['visibility_pwd'] = field_pwd.val(); + } + } + + this._rpc({ + model: 'website.page', + method: 'save_page_info', + args: [[context.website_id], params], + }).then(function (url) { + // If from page manager: reload url, if from page itself: go to + // (possibly) new url + var mo; + self.trigger_up('main_object_request', { + callback: function (value) { + mo = value; + }, + }); + if (mo.model === 'website.page') { + window.location.href = url.toLowerCase(); + } else { + window.location.reload(true); + } + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Retrieves the page URL dependencies for the given object id. + * + * @private + * @param {integer} moID + * @returns {Promise<Array>} + */ + _getPageDependencies: function (moID) { + return this._rpc({ + model: 'website', + method: 'page_search_dependencies', + args: [moID], + }); + }, + /** + * Retrieves the page's key dependencies for the given object id. + * + * @private + * @param {integer} moID + * @returns {Promise<Array>} + */ + _getPageKeyDependencies: function (moID) { + return this._rpc({ + model: 'website', + method: 'page_search_key_dependencies', + args: [moID], + }); + }, + /** + * Retrieves supported mimtype + * + * @private + * @returns {Promise<Array>} + */ + _getSupportedMimetype: function () { + return this._rpc({ + model: 'website', + method: 'guess_mimetype', + }); + }, + /** + * Returns information about the page main object. + * + * @private + * @returns {Object} model and id + */ + _getMainObject: function () { + var repr = $('html').data('main-object'); + var m = repr.match(/(.+)\((\d+),(.*)\)/); + return { + model: m[1], + id: m[2] | 0, + }; + }, + /** + * Converts a string representing the browser datetime + * (exemple: Albanian: '2018-Qer-22 15.12.35.') + * to a string representing UTC in Odoo's datetime string format + * (exemple: '2018-04-22 13:12:35'). + * + * The time zone of the datetime string is assumed to be the one of the + * browser and it will be converted to UTC (standard for Odoo). + * + * @private + * @param {String} value A string representing a datetime. + * @returns {String|false} A string representing an UTC datetime if the given value is valid, false otherwise. + */ + _parse_date: function (value) { + var datetime = moment(value, time.getLangDatetimeFormat(), true); + if (datetime.isValid()) { + return time.datetime_to_str(datetime.toDate()); + } + else { + return false; + } + }, + /** + * Allows the given input to propose existing groups. + * + * @param {jQuery} $input + */ + autocompleteWithGroups: function ($input) { + $input.autocomplete({ + source: (request, response) => { + return this._rpc({ + model: 'res.groups', + method: 'search_read', + args: [[['name', 'ilike', request.term]], ['display_name']], + kwargs: { + limit: 15, + }, + }).then(founds => { + founds = founds.map(g => ({'id': g['id'], 'label': g['display_name']})); + response(founds); + }); + }, + change: (ev, ui) => { + var $target = $(ev.target); + if (!ui.item) { + $target.val(""); + $target.removeData('group-id'); + } else { + $target.data('group-id', ui.item.id); + } + }, + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onUrlChanged: function () { + var url = this.$('input#page_url').val(); + this.$('.ask_for_redirect').toggleClass('d-none', url === this.page.url); + }, + /** + * @private + */ + _onNameChanged: function () { + var name = this.$('input#page_name').val(); + // If the file type is a supported mimetype, check if it is t-called. + // If so, warn user. Note: different from page_search_dependencies which + // check only for url and not key + var ext = '.' + this.page.name.split('.').pop(); + if (ext in this.supportedMimetype && ext !== '.html') { + this.$('.warn_about_call').toggleClass('d-none', name === this.page.name); + } + }, + /** + * @private + */ + _onCreateRedirectChanged: function () { + var createRedirect = this.$('input#create_redirect').prop('checked'); + this.$('.redirect_type').toggleClass('d-none', !createRedirect); + }, + /** + * @private + */ + _onVisibilityChanged: function (ev) { + this.$('.show_visibility_password').toggleClass('d-none', ev.target.value !== 'password'); + this.$('.show_group_id').toggleClass('d-none', ev.target.value !== 'restricted_group'); + this.$('#visibility_password').attr('required', ev.target.value === 'password'); + }, + /** + * Library clears the wrong date format so just ignore error + * + * @private + */ + _onDateTimePickerError: function (ev) { + return false; + }, + /** + * @private + */ + _onPasswordClicked: function (ev) { + ev.target.value = ''; + this._onPasswordChanged(); + }, + /** + * @private + */ + _onPasswordChanged: function () { + this.$('#visibility_password').data('dirty', 1); + }, +}); + +var MenuEntryDialog = weWidgets.LinkDialog.extend({ + xmlDependencies: weWidgets.LinkDialog.prototype.xmlDependencies.concat( + ['/website/static/src/xml/website.contentMenu.xml'] + ), + + /** + * @constructor + */ + init: function (parent, options, editable, data) { + this._super(parent, _.extend({ + title: _t("Add a menu item"), + }, options || {}), editable, _.extend({ + needLabel: true, + text: data.name || '', + isNewWindow: data.new_window, + }, data || {})); + + this.menuType = data.menuType; + }, + /** + * @override + */ + start: function () { + // Remove style related elements + this.$('.o_link_dialog_preview').remove(); + this.$('input[name="is_new_window"], .link-style').closest('.form-group').remove(); + this.$modal.find('.modal-lg').removeClass('modal-lg'); + this.$('form.col-lg-8').removeClass('col-lg-8').addClass('col-12'); + + // Adapt URL label + this.$('label[for="o_link_dialog_label_input"]').text(_t("Menu Label")); + + // Auto add '#' URL and hide the input if for mega menu + if (this.menuType === 'mega') { + var $url = this.$('input[name="url"]'); + $url.val('#').trigger('change'); + $url.closest('.form-group').addClass('d-none'); + } + + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + save: function () { + var $e = this.$('#o_link_dialog_label_input'); + if (!$e.val() || !$e[0].checkValidity()) { + $e.closest('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid'); + $e.focus(); + return; + } + return this._super.apply(this, arguments); + }, +}); + +var SelectEditMenuDialog = weWidgets.Dialog.extend({ + template: 'website.contentMenu.dialog.select', + xmlDependencies: weWidgets.Dialog.prototype.xmlDependencies.concat( + ['/website/static/src/xml/website.contentMenu.xml'] + ), + + /** + * @constructor + * @override + */ + init: function (parent, options) { + var self = this; + self.roots = [{id: null, name: _t("Top Menu")}]; + $('[data-content_menu_id]').each(function () { + // Remove name fallback in master + self.roots.push({id: $(this).data('content_menu_id'), name: $(this).attr('name') || $(this).data('menu_name')}); + }); + this._super(parent, _.extend({}, { + title: _t("Select a Menu"), + save_text: _t("Continue") + }, options || {})); + }, + /** + * @override + */ + save: function () { + this.final_data = parseInt(this.$el.find('select').val() || null); + this._super.apply(this, arguments); + }, +}); + +var EditMenuDialog = weWidgets.Dialog.extend({ + template: 'website.contentMenu.dialog.edit', + xmlDependencies: weWidgets.Dialog.prototype.xmlDependencies.concat( + ['/website/static/src/xml/website.contentMenu.xml'] + ), + events: _.extend({}, weWidgets.Dialog.prototype.events, { + 'click a.js_add_menu': '_onAddMenuButtonClick', + 'click button.js_delete_menu': '_onDeleteMenuButtonClick', + 'click button.js_edit_menu': '_onEditMenuButtonClick', + }), + + /** + * @constructor + * @override + */ + init: function (parent, options, rootID) { + this._super(parent, _.extend({}, { + title: _t("Edit Menu"), + size: 'medium', + }, options || {})); + this.rootID = rootID; + }, + /** + * @override + */ + willStart: function () { + var defs = [this._super.apply(this, arguments)]; + var context; + this.trigger_up('context_get', { + callback: function (ctx) { + context = ctx; + }, + }); + defs.push(this._rpc({ + model: 'website.menu', + method: 'get_tree', + args: [context.website_id, this.rootID], + }).then(menu => { + this.menu = menu; + this.rootMenuID = menu.fields['id']; + this.flat = this._flatenize(menu); + this.toDelete = []; + })); + return Promise.all(defs); + }, + /** + * @override + */ + start: function () { + var r = this._super.apply(this, arguments); + this.$('.oe_menu_editor').nestedSortable({ + listType: 'ul', + handle: 'div', + items: 'li', + maxLevels: 2, + toleranceElement: '> div', + forcePlaceholderSize: true, + opacity: 0.6, + placeholder: 'oe_menu_placeholder', + tolerance: 'pointer', + attribute: 'data-menu-id', + expression: '()(.+)', // nestedSortable takes the second match of an expression (*sigh*) + isAllowed: (placeholder, placeholderParent, currentItem) => { + return !placeholderParent + || !currentItem[0].dataset.megaMenu && !placeholderParent[0].dataset.megaMenu; + }, + }); + return r; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + save: function () { + var _super = this._super.bind(this); + var newMenus = this.$('.oe_menu_editor').nestedSortable('toArray', {startDepthCount: 0}); + var levels = []; + var data = []; + var context; + this.trigger_up('context_get', { + callback: function (ctx) { + context = ctx; + }, + }); + // Resequence, re-tree and remove useless data + newMenus.forEach(menu => { + if (menu.id) { + levels[menu.depth] = (levels[menu.depth] || 0) + 1; + var menuFields = this.flat[menu.id].fields; + menuFields['sequence'] = levels[menu.depth]; + menuFields['parent_id'] = menu['parent_id'] || this.rootMenuID; + data.push(menuFields); + } + }); + return this._rpc({ + model: 'website.menu', + method: 'save', + args: [ + context.website_id, + { + 'data': data, + 'to_delete': this.toDelete, + } + ], + }).then(function () { + return _super(); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns a mapping id -> menu item containing all the menu items in the + * given menu hierarchy. + * + * @private + * @param {Object} node + * @param {Object} [_dict] internal use: the mapping being built + * @returns {Object} + */ + _flatenize: function (node, _dict) { + _dict = _dict || {}; + _dict[node.fields['id']] = node; + node.children.forEach(child => { + this._flatenize(child, _dict); + }); + return _dict; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the "add menu" button is clicked -> Opens the appropriate + * dialog to edit this new menu. + * + * @private + * @param {Event} ev + */ + _onAddMenuButtonClick: function (ev) { + var menuType = ev.currentTarget.dataset.type; + var dialog = new MenuEntryDialog(this, {}, null, { + menuType: menuType, + }); + dialog.on('save', this, link => { + var newMenu = { + 'fields': { + 'id': _.uniqueId('new-'), + 'name': _.unescape(link.text), + 'url': link.url, + 'new_window': link.isNewWindow, + 'is_mega_menu': menuType === 'mega', + 'sequence': 0, + 'parent_id': false, + }, + 'children': [], + 'is_homepage': false, + }; + this.flat[newMenu.fields['id']] = newMenu; + this.$('.oe_menu_editor').append( + qweb.render('website.contentMenu.dialog.submenu', {submenu: newMenu}) + ); + }); + dialog.open(); + }, + /** + * Called when the "delete menu" button is clicked -> Deletes this menu. + * + * @private + */ + _onDeleteMenuButtonClick: function (ev) { + var $menu = $(ev.currentTarget).closest('[data-menu-id]'); + var menuID = parseInt($menu.data('menu-id')); + if (menuID) { + this.toDelete.push(menuID); + } + $menu.remove(); + }, + /** + * Called when the "edit menu" button is clicked -> Opens the appropriate + * dialog to edit this menu. + * + * @private + */ + _onEditMenuButtonClick: function (ev) { + var $menu = $(ev.currentTarget).closest('[data-menu-id]'); + var menuID = $menu.data('menu-id'); + var menu = this.flat[menuID]; + if (menu) { + var dialog = new MenuEntryDialog(this, {}, null, _.extend({ + menuType: menu.fields['is_mega_menu'] ? 'mega' : undefined, + }, menu.fields)); + dialog.on('save', this, link => { + _.extend(menu.fields, { + 'name': _.unescape(link.text), + 'url': link.url, + 'new_window': link.isNewWindow, + }); + $menu.find('.js_menu_label').first().text(menu.fields['name']); + }); + dialog.open(); + } else { + Dialog.alert(null, "Could not find menu entry"); + } + }, +}); + +var PageOption = Class.extend({ + /** + * @constructor + * @param {string} name + * the option's name = the field's name in website.page model + * @param {*} value + * @param {function} setValueCallback + * a function which simulates an option's value change without + * asking the server to change it + */ + init: function (name, value, setValueCallback) { + this.name = name; + this.value = value; + this.isDirty = false; + this.setValueCallback = setValueCallback; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Sets the new option's value thanks to the related callback. + * + * @param {*} [value] + * by default: consider the current value is a boolean and toggle it + */ + setValue: function (value) { + if (value === undefined) { + value = !this.value; + } + this.setValueCallback.call(this, value); + this.value = value; + this.isDirty = true; + }, +}); + +var ContentMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ + xmlDependencies: ['/website/static/src/xml/website.xml'], + actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, { + edit_menu: '_editMenu', + get_page_option: '_getPageOption', + on_save: '_onSave', + page_properties: '_pageProperties', + toggle_page_option: '_togglePageOption', + }), + pageOptionsSetValueCallbacks: { + header_overlay: function (value) { + $('#wrapwrap').toggleClass('o_header_overlay', value); + }, + header_color: function (value) { + $('#wrapwrap > header').removeClass(this.value) + .addClass(value); + }, + header_visible: function (value) { + $('#wrapwrap > header').toggleClass('d-none o_snippet_invisible', !value); + }, + footer_visible: function (value) { + $('#wrapwrap > footer').toggleClass('d-none o_snippet_invisible', !value); + }, + }, + + /** + * @override + */ + start: function () { + var self = this; + this.pageOptions = {}; + _.each($('.o_page_option_data'), function (el) { + var value = el.value; + if (value === "True") { + value = true; + } else if (value === "False") { + value = false; + } + self.pageOptions[el.name] = new PageOption( + el.name, + value, + self.pageOptionsSetValueCallbacks[el.name] + ); + }); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Actions + //-------------------------------------------------------------------------- + + /** + * Asks the user which menu to edit if multiple menus exist on the page. + * Then opens the menu edition dialog. + * Then executes the given callback once the edition is saved, to finally + * reload the page. + * + * @private + * @param {function} [beforeReloadCallback] + * @returns {Promise} + * Unresolved if the menu is edited and saved as the page will be + * reloaded. + * Resolved otherwise. + */ + _editMenu: function (beforeReloadCallback) { + var self = this; + return new Promise(function (resolve) { + function resolveWhenEditMenuDialogIsCancelled(rootID) { + return self._openEditMenuDialog(rootID, beforeReloadCallback).then(resolve); + } + if ($('[data-content_menu_id]').length) { + var select = new SelectEditMenuDialog(self); + select.on('save', self, resolveWhenEditMenuDialogIsCancelled); + select.on('cancel', self, resolve); + select.open(); + } else { + resolveWhenEditMenuDialogIsCancelled(null); + } + }); + }, + /** + * + * @param {*} rootID + * @param {function|undefied} beforeReloadCallback function that returns a promise + * @returns {Promise} + */ + _openEditMenuDialog: function (rootID, beforeReloadCallback) { + var self = this; + return new Promise(function (resolve) { + var dialog = new EditMenuDialog(self, {}, rootID); + dialog.on('save', self, function () { + // Before reloading the page after menu modification, does the + // given action to do. + if (beforeReloadCallback) { + // Reload the page so that the menu modification are shown + beforeReloadCallback().then(function () { + window.location.reload(true); + }); + } else { + window.location.reload(true); + } + }); + dialog.on('cancel', self, resolve); + dialog.open(); + }); + }, + + /** + * Retrieves the value of a page option. + * + * @private + * @param {string} name + * @returns {Promise<*>} + */ + _getPageOption: function (name) { + var option = this.pageOptions[name]; + if (!option) { + return Promise.reject(); + } + return Promise.resolve(option.value); + }, + /** + * On save, simulated page options have to be server-saved. + * + * @private + * @returns {Promise} + */ + _onSave: function () { + var self = this; + var defs = _.map(this.pageOptions, function (option, optionName) { + if (option.isDirty) { + return self._togglePageOption({ + name: optionName, + value: option.value, + }, true, true); + } + }); + return Promise.all(defs); + }, + /** + * Opens the page properties dialog. + * + * @private + * @returns {Promise} + */ + _pageProperties: function () { + var mo; + this.trigger_up('main_object_request', { + callback: function (value) { + mo = value; + }, + }); + var dialog = new PagePropertiesDialog(this, mo.id, {}).open(); + return dialog.opened(); + }, + /** + * Toggles a page option. + * + * @private + * @param {Object} params + * @param {string} params.name + * @param {*} [params.value] (change value by default true -> false -> true) + * @param {boolean} [forceSave=false] + * @param {boolean} [noReload=false] + * @returns {Promise} + */ + _togglePageOption: function (params, forceSave, noReload) { + // First check it is a website page + var mo; + this.trigger_up('main_object_request', { + callback: function (value) { + mo = value; + }, + }); + if (mo.model !== 'website.page') { + return Promise.reject(); + } + + // Check if this is a valid option + var option = this.pageOptions[params.name]; + if (!option) { + return Promise.reject(); + } + + // Toggle the value + option.setValue(params.value); + + // If simulate is true, it means we want the option to be toggled but + // not saved on the server yet + if (!forceSave) { + return Promise.resolve(); + } + + // If not, write on the server page and reload the current location + var vals = {}; + vals[params.name] = option.value; + var prom = this._rpc({ + model: 'website.page', + method: 'write', + args: [[mo.id], vals], + }); + if (noReload) { + return prom; + } + return prom.then(function () { + window.location.reload(); + return new Promise(function () {}); + }); + }, +}); + +var PageManagement = Widget.extend({ + xmlDependencies: ['/website/static/src/xml/website.xml'], + events: { + 'click a.js_page_properties': '_onPagePropertiesButtonClick', + 'click a.js_clone_page': '_onClonePageButtonClick', + 'click a.js_delete_page': '_onDeletePageButtonClick', + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Retrieves the page dependencies for the given object id. + * + * @private + * @param {integer} moID + * @returns {Promise<Array>} + */ + _getPageDependencies: function (moID) { + return this._rpc({ + model: 'website', + method: 'page_search_dependencies', + args: [moID], + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + _onPagePropertiesButtonClick: function (ev) { + var moID = $(ev.currentTarget).data('id'); + var dialog = new PagePropertiesDialog(this,moID, {'fromPageManagement': true}).open(); + return dialog; + }, + _onClonePageButtonClick: function (ev) { + var pageId = $(ev.currentTarget).data('id'); + _clonePage.call(this, pageId); + }, + _onDeletePageButtonClick: function (ev) { + var pageId = $(ev.currentTarget).data('id'); + _deletePage.call(this, pageId, true); + }, +}); + +/** + * Deletes the page after showing a dependencies warning for the given page id. + * + * @private + * @param {integer} pageId - The ID of the page to be deleted + * @param {Boolean} fromPageManagement + * Is the function called by the page manager? + * It will affect redirect after page deletion: reload or '/' + */ +// TODO: This function should be integrated in a widget in the future +function _deletePage(pageId, fromPageManagement) { + var self = this; + new Promise(function (resolve, reject) { + // Search the page dependencies + self._getPageDependencies(pageId) + .then(function (dependencies) { + // Inform the user about those dependencies and ask him confirmation + return new Promise(function (confirmResolve, confirmReject) { + Dialog.safeConfirm(self, "", { + title: _t("Delete Page"), + $content: $(qweb.render('website.delete_page', {dependencies: dependencies})), + confirm_callback: confirmResolve, + cancel_callback: resolve, + }); + }); + }).then(function () { + // Delete the page if the user confirmed + return self._rpc({ + model: 'website.page', + method: 'unlink', + args: [pageId], + }); + }).then(function () { + if (fromPageManagement) { + window.location.reload(true); + } else { + window.location.href = '/'; + } + }, reject); + }); +} +/** + * Duplicate the page after showing the wizard to enter new page name. + * + * @private + * @param {integer} pageId - The ID of the page to be duplicate + * + */ +function _clonePage(pageId) { + var self = this; + new Promise(function (resolve, reject) { + Dialog.confirm(this, undefined, { + title: _t("Duplicate Page"), + $content: $(qweb.render('website.duplicate_page_action_dialog')), + confirm_callback: function () { + var new_page_name = this.$('#page_name').val(); + return self._rpc({ + model: 'website.page', + method: 'clone_page', + args: [pageId, new_page_name], + }).then(function (path) { + window.location.href = path; + }).guardedCatch(reject); + }, + cancel_callback: reject, + }).on('closed', null, reject); + }); +} + +websiteNavbarData.websiteNavbarRegistry.add(ContentMenu, '#content-menu'); +websiteRootData.websiteRootRegistry.add(PageManagement, '#list_website_pages'); + +return { + PagePropertiesDialog: PagePropertiesDialog, + ContentMenu: ContentMenu, + EditMenuDialog: EditMenuDialog, + MenuEntryDialog: MenuEntryDialog, + SelectEditMenuDialog: SelectEditMenuDialog, +}; +}); diff --git a/addons/website/static/src/js/menu/customize.js b/addons/website/static/src/js/menu/customize.js new file mode 100644 index 00000000..4481d0f6 --- /dev/null +++ b/addons/website/static/src/js/menu/customize.js @@ -0,0 +1,219 @@ +odoo.define('website.customizeMenu', function (require) { +'use strict'; + +var core = require('web.core'); +var Widget = require('web.Widget'); +var websiteNavbarData = require('website.navbar'); +var WebsiteAceEditor = require('website.ace'); + +var qweb = core.qweb; + +var CustomizeMenu = Widget.extend({ + xmlDependencies: ['/website/static/src/xml/website.editor.xml'], + events: { + 'show.bs.dropdown': '_onDropdownShow', + 'click .dropdown-item[data-view-key]': '_onCustomizeOptionClick', + }, + + /** + * @override + */ + willStart: function () { + this.viewName = $(document.documentElement).data('view-xmlid'); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + start: function () { + if (!this.viewName) { + _.defer(this.destroy.bind(this)); + } + + if (this.$el.is('.show')) { + this._loadCustomizeOptions(); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Enables/Disables a view customization whose id is given. + * + * @private + * @param {string} viewKey + * @returns {Promise} + * Unresolved if the customization succeeded as the page will be + * reloaded. + * Rejected otherwise. + */ + _doCustomize: function (viewKey) { + return this._rpc({ + route: '/website/toggle_switchable_view', + params: { + 'view_key': viewKey, + }, + }).then(function () { + window.location.reload(); + return new Promise(function () {}); + }); + }, + /** + * Loads the information about the views which can be enabled/disabled on + * the current page and shows them as switchable elements in the menu. + * + * @private + * @return {Promise} + */ + _loadCustomizeOptions: function () { + if (this.__customizeOptionsLoaded) { + return Promise.resolve(); + } + this.__customizeOptionsLoaded = true; + + var $menu = this.$el.children('.dropdown-menu'); + return this._rpc({ + route: '/website/get_switchable_related_views', + params: { + key: this.viewName, + }, + }).then(function (result) { + var currentGroup = ''; + if (result.length) { + $menu.append($('<div/>', { + class: 'dropdown-divider', + role: 'separator', + })); + } + _.each(result, function (item) { + if (currentGroup !== item.inherit_id[1]) { + currentGroup = item.inherit_id[1]; + $menu.append('<li class="dropdown-header">' + currentGroup + '</li>'); + } + var $a = $('<a/>', {href: '#', class: 'dropdown-item', 'data-view-key': item.key, role: 'menuitem'}) + .append(qweb.render('website.components.switch', {id: 'switch-' + item.id, label: item.name})); + $a.find('input').prop('checked', !!item.active); + $menu.append($a); + }); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a view's related switchable element is clicked -> enable / + * disable the related view. + * + * @private + * @param {Event} ev + */ + _onCustomizeOptionClick: function (ev) { + ev.preventDefault(); + var viewKey = $(ev.currentTarget).data('viewKey'); + this._doCustomize(viewKey); + }, + /** + * @private + */ + _onDropdownShow: function () { + this._loadCustomizeOptions(); + }, +}); + +var AceEditorMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ + actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, { + close_all_widgets: '_hideEditor', + edit: '_enterEditMode', + ace: '_launchAce', + }), + + /** + * Launches the ace editor automatically when the corresponding hash is in + * the page URL. + * + * @override + */ + start: function () { + if (window.location.hash.substr(0, WebsiteAceEditor.prototype.hash.length) === WebsiteAceEditor.prototype.hash) { + this._launchAce(); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Actions + //-------------------------------------------------------------------------- + + /** + * When handling the "edit" website action, the ace editor has to be closed. + * + * @private + */ + _enterEditMode: function () { + this._hideEditor(); + }, + /** + * @private + */ + _hideEditor: function () { + if (this.globalEditor) { + this.globalEditor.do_hide(); + } + }, + /** + * Launches the ace editor to be able to edit the templates and scss files + * which are used by the current page. + * + * @private + * @returns {Promise} + */ + _launchAce: function () { + var self = this; + var prom = new Promise(function (resolve, reject) { + self.trigger_up('action_demand', { + actionName: 'close_all_widgets', + onSuccess: resolve, + }); + }); + prom.then(function () { + if (self.globalEditor) { + self.globalEditor.do_show(); + return Promise.resolve(); + } else { + var currentHash = window.location.hash; + var indexOfView = currentHash.indexOf("?res="); + var initialResID = undefined; + if (indexOfView >= 0) { + initialResID = currentHash.substr(indexOfView + ("?res=".length)); + var parsedResID = parseInt(initialResID, 10); + if (parsedResID) { + initialResID = parsedResID; + } + } + + self.globalEditor = new WebsiteAceEditor(self, $(document.documentElement).data('view-xmlid'), { + initialResID: initialResID, + defaultBundlesRestriction: [ + 'web.assets_frontend', + 'web.assets_frontend_minimal', + 'web.assets_frontend_lazy', + ], + }); + return self.globalEditor.appendTo(document.body); + } + }); + + return prom; + }, +}); + +websiteNavbarData.websiteNavbarRegistry.add(CustomizeMenu, '#customize-menu'); +websiteNavbarData.websiteNavbarRegistry.add(AceEditorMenu, '#html_editor'); + +return CustomizeMenu; +}); diff --git a/addons/website/static/src/js/menu/debug_manager.js b/addons/website/static/src/js/menu/debug_manager.js new file mode 100644 index 00000000..e932daa7 --- /dev/null +++ b/addons/website/static/src/js/menu/debug_manager.js @@ -0,0 +1,21 @@ +odoo.define('website.debugManager', function (require) { +'use strict'; + +var config = require('web.config'); +var DebugManager = require('web.DebugManager'); +var websiteNavbarData = require('website.navbar'); + +var DebugManagerMenu = websiteNavbarData.WebsiteNavbar.include({ + /** + * @override + */ + start: function () { + if (config.isDebug()) { + new DebugManager(this).prependTo(this.$('.o_menu_systray')); + } + return this._super.apply(this, arguments); + }, +}); + +return DebugManagerMenu; +}); diff --git a/addons/website/static/src/js/menu/edit.js b/addons/website/static/src/js/menu/edit.js new file mode 100644 index 00000000..d448b15d --- /dev/null +++ b/addons/website/static/src/js/menu/edit.js @@ -0,0 +1,256 @@ +odoo.define('website.editMenu', function (require) { +'use strict'; + +var core = require('web.core'); +var EditorMenu = require('website.editor.menu'); +var websiteNavbarData = require('website.navbar'); + +var _t = core._t; + +/** + * Adds the behavior when clicking on the 'edit' button (+ editor interaction) + */ +var EditPageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ + assetLibs: ['web_editor.compiled_assets_wysiwyg', 'website.compiled_assets_wysiwyg'], + + xmlDependencies: ['/website/static/src/xml/website.editor.xml'], + actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions, { + edit: '_startEditMode', + on_save: '_onSave', + }), + custom_events: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.custom_events || {}, { + content_will_be_destroyed: '_onContentWillBeDestroyed', + content_was_recreated: '_onContentWasRecreated', + snippet_will_be_cloned: '_onSnippetWillBeCloned', + snippet_cloned: '_onSnippetCloned', + snippet_dropped: '_onSnippetDropped', + edition_will_stopped: '_onEditionWillStop', + edition_was_stopped: '_onEditionWasStopped', + }), + + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + var context; + this.trigger_up('context_get', { + extra: true, + callback: function (ctx) { + context = ctx; + }, + }); + this._editorAutoStart = (context.editable && window.location.search.indexOf('enable_editor') >= 0); + var url = window.location.href.replace(/([?&])&*enable_editor[^&#]*&?/, '\$1'); + window.history.replaceState({}, null, url); + }, + /** + * Auto-starts the editor if necessary or add the welcome message otherwise. + * + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + + // If we auto start the editor, do not show a welcome message + if (this._editorAutoStart) { + return Promise.all([def, this._startEditMode()]); + } + + // Check that the page is empty + var $wrap = this._targetForEdition().filter('#wrapwrap.homepage').find('#wrap'); + + if ($wrap.length && $wrap.html().trim() === '') { + // If readonly empty page, show the welcome message + this.$welcomeMessage = $(core.qweb.render('website.homepage_editor_welcome_message')); + this.$welcomeMessage.addClass('o_homepage_editor_welcome_message'); + this.$welcomeMessage.css('min-height', $wrap.parent('main').height() - ($wrap.outerHeight(true) - $wrap.height())); + $wrap.empty().append(this.$welcomeMessage); + } + + return def; + }, + + //-------------------------------------------------------------------------- + // Actions + //-------------------------------------------------------------------------- + + /** + * Creates an editor instance and appends it to the DOM. Also remove the + * welcome message if necessary. + * + * @private + * @returns {Promise} + */ + _startEditMode: async function () { + var self = this; + if (this.editModeEnable) { + return; + } + this.trigger_up('widgets_stop_request', { + $target: this._targetForEdition(), + }); + if (this.$welcomeMessage) { + this.$welcomeMessage.detach(); // detach from the readonly rendering before the clone by summernote + } + this.editModeEnable = true; + await new EditorMenu(this).prependTo(document.body); + this._addEditorMessages(); + var res = await new Promise(function (resolve, reject) { + self.trigger_up('widgets_start_request', { + editableMode: true, + onSuccess: resolve, + onFailure: reject, + }); + }); + // Trigger a mousedown on the main edition area to focus it, + // which is required for Summernote to activate. + this.$editorMessageElements.mousedown(); + return res; + }, + /** + * On save, the editor will ask to parent widgets if something needs to be + * done first. The website navbar will receive that demand and asks to its + * action-capable components to do something. For example, the content menu + * handles page-related options saving. However, some users with limited + * access rights do not have the content menu... but the website navbar + * expects that the save action is performed. So, this empty action is + * defined here so that all users have an 'on_save' related action. + * + * @private + * @todo improve the system to somehow declare required/optional actions + */ + _onSave: function () {}, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds automatic editor messages on drag&drop zone elements. + * + * @private + */ + _addEditorMessages: function () { + const $target = this._targetForEdition(); + const $skeleton = $target.find('.oe_structure.oe_empty, [data-oe-type="html"]'); + this.$editorMessageElements = $skeleton.not('[data-editor-message]').attr('data-editor-message', _t('DRAG BUILDING BLOCKS HERE')); + $skeleton.attr('contenteditable', function () { return !$(this).is(':empty'); }); + }, + /** + * Returns the target for edition. + * + * @private + * @returns {JQuery} + */ + _targetForEdition: function () { + return $('#wrapwrap'); // TODO should know about this element another way + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when content will be destroyed in the page. Notifies the + * WebsiteRoot that is should stop the public widgets. + * + * @private + * @param {OdooEvent} ev + */ + _onContentWillBeDestroyed: function (ev) { + this.trigger_up('widgets_stop_request', { + $target: ev.data.$target, + }); + }, + /** + * Called when content was recreated in the page. Notifies the + * WebsiteRoot that is should start the public widgets. + * + * @private + * @param {OdooEvent} ev + */ + _onContentWasRecreated: function (ev) { + this.trigger_up('widgets_start_request', { + editableMode: true, + $target: ev.data.$target, + }); + }, + /** + * Called when edition will stop. Notifies the + * WebsiteRoot that is should stop the public widgets. + * + * @private + * @param {OdooEvent} ev + */ + _onEditionWillStop: function (ev) { + this.$editorMessageElements && this.$editorMessageElements.removeAttr('data-editor-message'); + this.trigger_up('widgets_stop_request', { + $target: this._targetForEdition(), + }); + }, + /** + * Called when edition was stopped. Notifies the + * WebsiteRoot that is should start the public widgets. + * + * @private + * @param {OdooEvent} ev + */ + _onEditionWasStopped: function (ev) { + this.trigger_up('widgets_start_request', { + $target: this._targetForEdition(), + }); + this.editModeEnable = false; + }, + /** + * Called when a snippet is about to be cloned in the page. Notifies the + * WebsiteRoot that is should destroy the animations for this snippet. + * + * @private + * @param {OdooEvent} ev + */ + _onSnippetWillBeCloned: function (ev) { + this.trigger_up('widgets_stop_request', { + $target: ev.data.$target, + }); + }, + /** + * Called when a snippet is cloned in the page. Notifies the WebsiteRoot + * that is should start the public widgets for this snippet and the snippet it + * was cloned from. + * + * @private + * @param {OdooEvent} ev + */ + _onSnippetCloned: function (ev) { + this.trigger_up('widgets_start_request', { + editableMode: true, + $target: ev.data.$target, + }); + // TODO: remove in saas-12.5, undefined $origin will restart #wrapwrap + if (ev.data.$origin) { + this.trigger_up('widgets_start_request', { + editableMode: true, + $target: ev.data.$origin, + }); + } + }, + /** + * Called when a snippet is dropped in the page. Notifies the WebsiteRoot + * that is should start the public widgets for this snippet. Also add the + * editor messages. + * + * @private + * @param {OdooEvent} ev + */ + _onSnippetDropped: function (ev) { + this.trigger_up('widgets_start_request', { + editableMode: true, + $target: ev.data.$target, + }); + this._addEditorMessages(); + }, +}); + +websiteNavbarData.websiteNavbarRegistry.add(EditPageMenu, '#edit-page-menu'); +}); diff --git a/addons/website/static/src/js/menu/mobile_view.js b/addons/website/static/src/js/menu/mobile_view.js new file mode 100644 index 00000000..668962c8 --- /dev/null +++ b/addons/website/static/src/js/menu/mobile_view.js @@ -0,0 +1,68 @@ +odoo.define('website.mobile', function (require) { +'use strict'; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var websiteNavbarData = require('website.navbar'); + +var _t = core._t; + +var MobilePreviewDialog = Dialog.extend({ + /** + * Tweaks the modal so that it appears as a phone and modifies the iframe + * rendering to show more accurate mobile view. + * + * @override + */ + start: function () { + var self = this; + this.$modal.addClass('oe_mobile_preview'); + this.$modal.on('click', '.modal-header', function () { + self.$el.toggleClass('o_invert_orientation'); + }); + this.$iframe = $('<iframe/>', { + id: 'mobile-viewport', + src: $.param.querystring(window.location.href, 'mobilepreview'), + }); + this.$iframe.on('load', function (e) { + self.$iframe.contents().find('body').removeClass('o_connected_user'); + self.$iframe.contents().find('#oe_main_menu_navbar').remove(); + }); + this.$iframe.appendTo(this.$el); + + return this._super.apply(this, arguments); + }, +}); + +var MobileMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ + actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, { + 'show-mobile-preview': '_onMobilePreviewClick', + }), + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the mobile action is triggered -> instantiate the mobile + * preview dialog. + * + * @private + */ + _onMobilePreviewClick: function () { + if (this.mobilePreview && !this.mobilePreview.isDestroyed()) { + return this.mobilePreview.close(); + } + this.mobilePreview = new MobilePreviewDialog(this, { + title: _t('Mobile preview') + ' <span class="fa fa-refresh"/>', + }).open(); + }, +}); + +websiteNavbarData.websiteNavbarRegistry.add(MobileMenu, '#mobile-menu'); + +return { + MobileMenu: MobileMenu, + MobilePreviewDialog: MobilePreviewDialog, +}; +}); diff --git a/addons/website/static/src/js/menu/navbar.js b/addons/website/static/src/js/menu/navbar.js new file mode 100644 index 00000000..937392f8 --- /dev/null +++ b/addons/website/static/src/js/menu/navbar.js @@ -0,0 +1,292 @@ +odoo.define('website.navbar', function (require) { +'use strict'; + +var core = require('web.core'); +var dom = require('web.dom'); +var publicWidget = require('web.public.widget'); +var concurrency = require('web.concurrency'); +var Widget = require('web.Widget'); +var websiteRootData = require('website.root'); + +var websiteNavbarRegistry = new publicWidget.RootWidgetRegistry(); + +var WebsiteNavbar = publicWidget.RootWidget.extend({ + xmlDependencies: ['/website/static/src/xml/website.xml'], + events: _.extend({}, publicWidget.RootWidget.prototype.events || {}, { + 'click [data-action]': '_onActionMenuClick', + 'mouseover > ul > li.dropdown:not(.show)': '_onMenuHovered', + 'click .o_mobile_menu_toggle': '_onMobileMenuToggleClick', + 'mouseenter #oe_applications:not(:has(.dropdown-item))': '_onOeApplicationsHovered', + 'show.bs.dropdown #oe_applications:not(:has(.dropdown-item))': '_onOeApplicationsShow', + }), + custom_events: _.extend({}, publicWidget.RootWidget.prototype.custom_events || {}, { + 'action_demand': '_onActionDemand', + 'edit_mode': '_onEditMode', + 'readonly_mode': '_onReadonlyMode', + 'ready_to_save': '_onSave', + }), + + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + var self = this; + var initPromise = new Promise(function (resolve) { + self.resolveInit = resolve; + }); + this._widgetDefs = [initPromise]; + }, + /** + * @override + */ + start: function () { + var self = this; + dom.initAutoMoreMenu(this.$('ul.o_menu_sections'), { + maxWidth: function () { + // The navbar contains different elements in community and + // enterprise, so we check for both of them here only + return self.$el.width() + - (self.$('.o_menu_systray').outerWidth(true) || 0) + - (self.$('ul#oe_applications').outerWidth(true) || 0) + - (self.$('.o_menu_toggle').outerWidth(true) || 0) + - (self.$('.o_menu_brand').outerWidth(true) || 0); + }, + }); + return this._super.apply(this, arguments).then(function () { + self.resolveInit(); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _attachComponent: function () { + var def = this._super.apply(this, arguments); + this._widgetDefs.push(def); + return def; + }, + /** + * As the WebsiteNavbar instance is designed to be unique, the associated + * registry has been instantiated outside of the class and is simply + * returned here. + * + * @override + */ + _getRegistry: function () { + return websiteNavbarRegistry; + }, + /** + * Searches for the automatic widget {@see RootWidget} which can handle that + * action. + * + * @private + * @param {string} actionName + * @param {Array} params + * @returns {Promise} + */ + _handleAction: function (actionName, params, _i) { + var self = this; + return this._whenReadyForActions().then(function () { + var defs = []; + _.each(self._widgets, function (w) { + if (!w.handleAction) { + return; + } + + var def = w.handleAction(actionName, params); + if (def !== null) { + defs.push(def); + } + }); + if (!defs.length) { + // Handle the case where all action-capable components are not + // instantiated yet (rare) -> retry some times to eventually abort + if (_i > 50) { + console.warn(_.str.sprintf("Action '%s' was not able to be handled.", actionName)); + return Promise.reject(); + } + return concurrency.delay(100).then(function () { + return self._handleAction(actionName, params, (_i || 0) + 1); + }); + } + return Promise.all(defs).then(function (values) { + if (values.length === 1) { + return values[0]; + } + return values; + }); + }); + }, + /** + * @private + * @returns {Promise} + */ + async _loadAppMenus() { + if (!this._loadAppMenusProm) { + this._loadAppMenusProm = this._rpc({ + model: 'ir.ui.menu', + method: 'load_menus_root', + args: [], + }); + const result = await this._loadAppMenusProm; + const menus = core.qweb.render('website.oe_applications_menu', { + 'menu_data': result, + }); + this.$('#oe_applications .dropdown-menu').html(menus); + } + return this._loadAppMenusProm; + }, + /** + * @private + */ + _whenReadyForActions: function () { + return Promise.all(this._widgetDefs); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the backend applications menu is hovered -> fetch the + * available menus and insert it in DOM. + * + * @private + */ + _onOeApplicationsHovered: function () { + this._loadAppMenus(); + }, + /** + * Called when the backend applications menu is opening -> fetch the + * available menus and insert it in DOM. Needed on top of hovering as the + * dropdown could be opened via keyboard (or the user could just already + * be over the dropdown when the JS is fully loaded). + * + * @private + */ + _onOeApplicationsShow: function () { + this._loadAppMenus(); + }, + /** + * Called when an action menu is clicked -> searches for the automatic + * widget {@see RootWidget} which can handle that action. + * + * @private + * @param {Event} ev + */ + _onActionMenuClick: function (ev) { + const restore = dom.addButtonLoadingEffect(ev.currentTarget); + this._handleAction($(ev.currentTarget).data('action')).then(restore).guardedCatch(restore); + }, + /** + * Called when an action is asked to be executed from a child widget -> + * searches for the automatic widget {@see RootWidget} which can handle + * that action. + */ + _onActionDemand: function (ev) { + var def = this._handleAction(ev.data.actionName, ev.data.params); + if (ev.data.onSuccess) { + def.then(ev.data.onSuccess); + } + if (ev.data.onFailure) { + def.guardedCatch(ev.data.onFailure); + } + }, + /** + * Called in response to edit mode activation -> hides the navbar. + * + * @private + */ + _onEditMode: function () { + this.$el.addClass('editing_mode'); + this.do_hide(); + }, + /** + * Called when a submenu is hovered -> automatically opens it if another + * menu was already opened. + * + * @private + * @param {Event} ev + */ + _onMenuHovered: function (ev) { + var $opened = this.$('> ul > li.dropdown.show'); + if ($opened.length) { + $opened.find('.dropdown-toggle').dropdown('toggle'); + $(ev.currentTarget).find('.dropdown-toggle').dropdown('toggle'); + } + }, + /** + * Called when the mobile menu toggle button is click -> modifies the DOM + * to open the mobile menu. + * + * @private + */ + _onMobileMenuToggleClick: function () { + this.$el.parent().toggleClass('o_mobile_menu_opened'); + }, + /** + * Called in response to edit mode activation -> hides the navbar. + * + * @private + */ + _onReadonlyMode: function () { + this.$el.removeClass('editing_mode'); + this.do_show(); + }, + /** + * Called in response to edit mode saving -> checks if action-capable + * children have something to save. + * + * @private + * @param {OdooEvent} ev + */ + _onSave: function (ev) { + ev.data.defs.push(this._handleAction('on_save')); + }, +}); + +var WebsiteNavbarActionWidget = Widget.extend({ + /** + * 'Action name' -> 'Handler name' object + * + * Any [data-action="x"] element inside the website navbar will + * automatically trigger an action "x". This action can then be handled by + * any `WebsiteNavbarActionWidget` instance if the action name "x" is + * registered in this `actions` object. + */ + actions: {}, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Checks if the widget can execute an action whose name is given, with the + * given parameters. If it is the case, execute that action. + * + * @param {string} actionName + * @param {Array} params + * @returns {Promise|null} action's promise or null if no action was found + */ + handleAction: function (actionName, params) { + var action = this[this.actions[actionName]]; + if (action) { + return Promise.resolve(action.apply(this, params || [])); + } + return null; + }, +}); + +websiteRootData.websiteRootRegistry.add(WebsiteNavbar, '#oe_main_menu_navbar'); + +return { + WebsiteNavbar: WebsiteNavbar, + websiteNavbarRegistry: websiteNavbarRegistry, + WebsiteNavbarActionWidget: WebsiteNavbarActionWidget, +}; +}); diff --git a/addons/website/static/src/js/menu/new_content.js b/addons/website/static/src/js/menu/new_content.js new file mode 100644 index 00000000..8d541210 --- /dev/null +++ b/addons/website/static/src/js/menu/new_content.js @@ -0,0 +1,350 @@ +odoo.define('website.newMenu', function (require) { +'use strict'; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var websiteNavbarData = require('website.navbar'); +var wUtils = require('website.utils'); +var tour = require('web_tour.tour'); + +const {qweb, _t} = core; + +var enableFlag = 'enable_new_content'; + +var NewContentMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ + xmlDependencies: ['/website/static/src/xml/website.editor.xml'], + actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, { + close_all_widgets: '_handleCloseDemand', + new_page: '_createNewPage', + }), + events: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.events || {}, { + 'click': '_onBackgroundClick', + 'click [data-module-id]': '_onModuleIdClick', + 'keydown': '_onBackgroundKeydown', + }), + // allow text to be customized with inheritance + newContentText: { + failed: _t('Failed to install "%s"'), + installInProgress: _t("The installation of an App is already in progress."), + installNeeded: _t('Do you want to install the "%s" App?'), + installPleaseWait: _t('Installing "%s"'), + }, + + /** + * Prepare the navigation and find the modules to install. + * Move not installed module buttons after installed modules buttons, + * but keep the original index to be able to move back the pending install + * button at its final position, so the user can click at the same place. + * + * @override + */ + start: function () { + this.pendingInstall = false; + this.$newContentMenuChoices = this.$('#o_new_content_menu_choices'); + + var $modules = this.$newContentMenuChoices.find('.o_new_content_element'); + _.each($modules, function (el, index) { + var $el = $(el); + $el.data('original-index', index); + if ($el.data('module-id')) { + $el.appendTo($el.parent()); + $el.find('a i, a p').addClass('o_uninstalled_module'); + } + }); + + this.$firstLink = this.$newContentMenuChoices.find('a:eq(0)'); + this.$lastLink = this.$newContentMenuChoices.find('a:last'); + + if ($.deparam.querystring()[enableFlag] !== undefined) { + Object.keys(tour.tours).forEach( + el => { + let element = tour.tours[el]; + if (element.steps[0].trigger == '#new-content-menu > a' + && !element.steps[0].extra_trigger) { + element.steps[0].auto = true; + } + } + ); + this._showMenu(); + } + this.$loader = $(qweb.render('website.new_content_loader')); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Actions + //-------------------------------------------------------------------------- + + /** + * Asks the user information about a new page to create, then creates it and + * redirects the user to this new page. + * + * @private + * @returns {Promise} Unresolved if there is a redirection + */ + _createNewPage: function () { + return wUtils.prompt({ + id: 'editor_new_page', + window_title: _t("New Page"), + input: _t("Page Title"), + init: function () { + var $group = this.$dialog.find('div.form-group'); + $group.removeClass('mb0'); + + var $add = $('<div/>', {'class': 'form-group mb0 row'}) + .append($('<span/>', {'class': 'offset-md-3 col-md-9 text-left'}) + .append(qweb.render('website.components.switch', {id: 'switch_addTo_menu', label: _t("Add to menu")}))); + $add.find('input').prop('checked', true); + $group.after($add); + } + }).then(function (result) { + var val = result.val; + var $dialog = result.dialog; + if (!val) { + return; + } + var url = '/website/add/' + encodeURIComponent(val); + const res = wUtils.sendRequest(url, { + add_menu: $dialog.find('input[type="checkbox"]').is(':checked') || '', + }); + return new Promise(function () {}); + }); + }, + /** + * @private + */ + _handleCloseDemand: function () { + this._hideMenu(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Set the focus on the first link + * + * @private + */ + _focusFirstLink: function () { + this.$firstLink.focus(); + }, + /** + * Set the focus on the last link + * + * @private + */ + _focusLastLink: function () { + this.$lastLink.focus(); + }, + /** + * Hide the menu + * + * @private + */ + _hideMenu: function () { + this.shown = false; + this.$newContentMenuChoices.addClass('o_hidden'); + $('body').removeClass('o_new_content_open'); + }, + /** + * Install a module + * + * @private + * @param {number} moduleId: the module to install + * @return {Promise} + */ + _install: function (moduleId) { + this.pendingInstall = true; + $('body').css('pointer-events', 'none'); + return this._rpc({ + model: 'ir.module.module', + method: 'button_immediate_install', + args: [[moduleId]], + }).guardedCatch(function () { + $('body').css('pointer-events', ''); + }); + }, + /** + * Show the menu + * + * @private + * @returns {Promise} + */ + _showMenu: function () { + var self = this; + return new Promise(function (resolve, reject) { + self.trigger_up('action_demand', { + actionName: 'close_all_widgets', + onSuccess: resolve, + }); + }).then(function () { + self.firstTab = true; + self.shown = true; + self.$newContentMenuChoices.removeClass('o_hidden'); + $('body').addClass('o_new_content_open'); + self.$('> a').focus(); + + wUtils.removeLoader(); + }); + }, + /** + * Called to add loader element in DOM. + * + * @param {string} moduleName + * @private + */ + _addLoader(moduleName) { + const newContentLoaderText = _.str.sprintf(_t("Building your %s"), moduleName); + this.$loader.find('#new_content_loader_text').replaceWith(newContentLoaderText); + $('body').append(this.$loader); + }, + /** + * @private + */ + _removeLoader() { + this.$loader.remove(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the menu's toggle button is clicked: + * -> Opens the menu and reset the tab navigation (if closed) + * -> Close the menu (if open) + * Called when a click outside the menu's options occurs -> Close the menu + * + * @private + * @param {Event} ev + */ + _onBackgroundClick: function (ev) { + if (this.$newContentMenuChoices.hasClass('o_hidden')) { + this._showMenu(); + } else { + this._hideMenu(); + } + }, + /** + * Called when a keydown occurs: + * ESC -> Closes the modal + * TAB -> Navigation (captured in the modal) + * + * @private + * @param {Event} ev + */ + _onBackgroundKeydown: function (ev) { + if (!this.shown) { + return; + } + switch (ev.which) { + case $.ui.keyCode.ESCAPE: + this._hideMenu(); + ev.stopPropagation(); + break; + case $.ui.keyCode.TAB: + if (ev.shiftKey) { + if (this.firstTab || document.activeElement === this.$firstLink[0]) { + this._focusLastLink(); + ev.preventDefault(); + } + } else { + if (this.firstTab || document.activeElement === this.$lastLink[0]) { + this._focusFirstLink(); + ev.preventDefault(); + } + } + this.firstTab = false; + break; + } + }, + /** + * Open the install dialog related to an element: + * - open the dialog depending on access right and another pending install + * - if ok to install, prepare the install action: + * - call the proper action on click + * - change the button text and style + * - handle the result (reload on the same page or error) + * + * @private + * @param {Event} ev + */ + _onModuleIdClick: function (ev) { + var self = this; + var $el = $(ev.currentTarget); + var $i = $el.find('a i'); + var $p = $el.find('a p'); + + var title = $p.text(); + var content = ''; + var buttons; + + var moduleId = $el.data('module-id'); + var name = $el.data('module-shortdesc'); + + ev.stopPropagation(); + ev.preventDefault(); + + if (this.pendingInstall) { + content = this.newContentText.installInProgress; + } else { + content = _.str.sprintf(this.newContentText.installNeeded, name); + buttons = [{ + text: _t("Install"), + classes: 'btn-primary', + close: true, + click: function () { + // move the element where it will be after installation + var $finalPosition = self.$newContentMenuChoices + .find('.o_new_content_element:not([data-module-id])') + .filter(function () { + return $(this).data('original-index') < $el.data('original-index'); + }).last(); + if ($finalPosition) { + $el.fadeTo(400, 0, function () { + // if once installed, button disapeear, don't need to move it. + if (!$el.hasClass('o_new_content_element_once')) { + $el.insertAfter($finalPosition); + } + // change style to use spinner + $i.removeClass() + .addClass('fa fa-spin fa-spinner fa-pulse') + .css('background-image', 'none'); + $p.removeClass('o_uninstalled_module') + .text(_.str.sprintf(self.newContentText.installPleaseWait, name)); + $el.fadeTo(1000, 1); + self._addLoader(name); + }); + } + + self._install(moduleId).then(function () { + var origin = window.location.origin; + var redirectURL = $el.find('a').data('url') || (window.location.pathname + '?' + enableFlag); + window.location.href = origin + redirectURL; + self._removeLoader(); + }, function () { + $i.removeClass() + .addClass('fa fa-exclamation-triangle'); + $p.text(_.str.sprintf(self.newContentText.failed, name)); + }); + } + }, { + text: _t("Cancel"), + close: true, + }]; + } + + new Dialog(this, { + title: title, + size: 'medium', + $content: $('<div/>', {text: content}), + buttons: buttons + }).open(); + }, +}); + +websiteNavbarData.websiteNavbarRegistry.add(NewContentMenu, '.o_new_content_menu'); + +return NewContentMenu; +}); diff --git a/addons/website/static/src/js/menu/seo.js b/addons/website/static/src/js/menu/seo.js new file mode 100644 index 00000000..b724bc1a --- /dev/null +++ b/addons/website/static/src/js/menu/seo.js @@ -0,0 +1,902 @@ +odoo.define('website.seo', function (require) { +'use strict'; + +var core = require('web.core'); +var Class = require('web.Class'); +var Dialog = require('web.Dialog'); +var mixins = require('web.mixins'); +var rpc = require('web.rpc'); +var Widget = require('web.Widget'); +var weWidgets = require('wysiwyg.widgets'); +var websiteNavbarData = require('website.navbar'); + +var _t = core._t; + +// This replaces \b, because accents(e.g. à, é) are not seen as word boundaries. +// Javascript \b is not unicode aware, and words beginning or ending by accents won't match \b +var WORD_SEPARATORS_REGEX = '([\\u2000-\\u206F\\u2E00-\\u2E7F\'!"#\\$%&\\(\\)\\*\\+,\\-\\.\\/:;<=>\\?¿¡@\\[\\]\\^_`\\{\\|\\}~\\s]+|^|$)'; + +var Suggestion = Widget.extend({ + template: 'website.seo_suggestion', + xmlDependencies: ['/website/static/src/xml/website.seo.xml'], + events: { + 'click .o_seo_suggestion': 'select', + }, + + init: function (parent, options) { + this.keyword = options.keyword; + this._super(parent); + }, + select: function () { + this.trigger('selected', this.keyword); + }, +}); + +var SuggestionList = Widget.extend({ + template: 'website.seo_suggestion_list', + xmlDependencies: ['/website/static/src/xml/website.seo.xml'], + + init: function (parent, options) { + this.root = options.root; + this.language = options.language; + this.htmlPage = options.htmlPage; + this._super(parent); + }, + start: function () { + this.refresh(); + }, + refresh: function () { + var self = this; + self.$el.append(_t("Loading...")); + var context; + this.trigger_up('context_get', { + callback: function (ctx) { + context = ctx; + }, + }); + var language = self.language || context.lang.toLowerCase(); + this._rpc({ + route: '/website/seo_suggest', + params: { + keywords: self.root, + lang: language, + }, + }).then(function (keyword_list) { + self.addSuggestions(JSON.parse(keyword_list)); + }); + }, + addSuggestions: function (keywords) { + var self = this; + self.$el.empty(); + // TODO Improve algorithm + Ajust based on custom user keywords + var regex = new RegExp(WORD_SEPARATORS_REGEX + self.root + WORD_SEPARATORS_REGEX, 'gi'); + keywords = _.map(_.uniq(keywords), function (word) { + return word.replace(regex, '').trim(); + }); + // TODO Order properly ? + _.each(keywords, function (keyword) { + if (keyword) { + var suggestion = new Suggestion(self, { + keyword: keyword, + }); + suggestion.on('selected', self, function (word, language) { + self.trigger('selected', word, language); + }); + suggestion.appendTo(self.$el); + } + }); + }, +}); + +var Keyword = Widget.extend({ + template: 'website.seo_keyword', + xmlDependencies: ['/website/static/src/xml/website.seo.xml'], + events: { + 'click a[data-action=remove-keyword]': 'destroy', + }, + + init: function (parent, options) { + this.keyword = options.word; + this.language = options.language; + this.htmlPage = options.htmlPage; + this.used_h1 = this.htmlPage.isInHeading1(this.keyword); + this.used_h2 = this.htmlPage.isInHeading2(this.keyword); + this.used_content = this.htmlPage.isInBody(this.keyword); + this._super(parent); + }, + start: function () { + var self = this; + this.$('.o_seo_keyword_suggestion').empty(); + this.suggestionList = new SuggestionList(this, { + root: this.keyword, + language: this.language, + htmlPage: this.htmlPage, + }); + this.suggestionList.on('selected', this, function (word, language) { + this.trigger('selected', word, language); + }); + return this.suggestionList.appendTo(this.$('.o_seo_keyword_suggestion')).then(function() { + self.htmlPage.on('title-changed', self, self._updateTitle); + self.htmlPage.on('description-changed', self, self._updateDescription); + self._updateTitle(); + self._updateDescription(); + }); + }, + destroy: function () { + this.trigger('removed'); + this._super(); + }, + _updateTitle: function () { + var $title = this.$('.js_seo_keyword_title'); + if (this.htmlPage.isInTitle(this.keyword)) { + $title.css('visibility', 'visible'); + } else { + $title.css('visibility', 'hidden'); + } + }, + _updateDescription: function () { + var $description = this.$('.js_seo_keyword_description'); + if (this.htmlPage.isInDescription(this.keyword)) { + $description.css('visibility', 'visible'); + } else { + $description.css('visibility', 'hidden'); + } + }, +}); + +var KeywordList = Widget.extend({ + template: 'website.seo_list', + xmlDependencies: ['/website/static/src/xml/website.seo.xml'], + maxKeywords: 10, + + init: function (parent, options) { + this.htmlPage = options.htmlPage; + this._super(parent); + }, + start: function () { + var self = this; + var existingKeywords = self.htmlPage.keywords(); + if (existingKeywords.length > 0) { + _.each(existingKeywords, function (word) { + self.add.call(self, word); + }); + } + }, + keywords: function () { + var result = []; + this.$('.js_seo_keyword').each(function () { + result.push($(this).data('keyword')); + }); + return result; + }, + isFull: function () { + return this.keywords().length >= this.maxKeywords; + }, + exists: function (word) { + return _.contains(this.keywords(), word); + }, + add: async function (candidate, language) { + var self = this; + // TODO Refine + var word = candidate ? candidate.replace(/[,;.:<>]+/g, ' ').replace(/ +/g, ' ').trim().toLowerCase() : ''; + if (word && !self.isFull() && !self.exists(word)) { + var keyword = new Keyword(self, { + word: word, + language: language, + htmlPage: this.htmlPage, + }); + keyword.on('removed', self, function () { + self.trigger('list-not-full'); + self.trigger('content-updated', true); + }); + keyword.on('selected', self, function (word, language) { + self.trigger('selected', word, language); + }); + await keyword.appendTo(self.$el); + } + if (self.isFull()) { + self.trigger('list-full'); + } + self.trigger('content-updated'); + }, +}); + +var Preview = Widget.extend({ + template: 'website.seo_preview', + xmlDependencies: ['/website/static/src/xml/website.seo.xml'], + + init: function (parent, options) { + this.title = options.title; + this.url = options.url; + this.description = options.description; + if (this.description.length > 160) { + this.description = this.description.substring(0, 159) + '…'; + } + this._super(parent); + }, +}); + +var HtmlPage = Class.extend(mixins.PropertiesMixin, { + init: function () { + mixins.PropertiesMixin.init.call(this); + this.initTitle = this.title(); + this.defaultTitle = $('meta[name="default_title"]').attr('content'); + this.initDescription = this.description(); + }, + url: function () { + return window.location.origin + window.location.pathname; + }, + title: function () { + return $('title').text().trim(); + }, + changeTitle: function (title) { + // TODO create tag if missing + $('title').text(title.trim() || this.defaultTitle); + this.trigger('title-changed', title); + }, + description: function () { + return ($('meta[name=description]').attr('content') || '').trim(); + }, + changeDescription: function (description) { + // TODO create tag if missing + $('meta[name=description]').attr('content', description); + this.trigger('description-changed', description); + }, + keywords: function () { + var $keywords = $('meta[name=keywords]'); + var parsed = ($keywords.length > 0) && $keywords.attr('content') && $keywords.attr('content').split(','); + return (parsed && parsed[0]) ? parsed: []; + }, + changeKeywords: function (keywords) { + // TODO create tag if missing + $('meta[name=keywords]').attr('content', keywords.join(',')); + }, + headers: function (tag) { + return $('#wrap '+tag).map(function () { + return $(this).text(); + }); + }, + getOgMeta: function () { + var ogImageUrl = $('meta[property="og:image"]').attr('content'); + var title = $('meta[property="og:title"]').attr('content'); + var description = $('meta[property="og:description"]').attr('content'); + return { + ogImageUrl: ogImageUrl && ogImageUrl.replace(window.location.origin, ''), + metaTitle: title, + metaDescription: description, + }; + }, + images: function () { + return $('#wrap img').filter(function () { + return this.naturalHeight >= 200 && this.naturalWidth >= 200; + }).map(function () { + return { + src: this.getAttribute('src'), + alt: this.getAttribute('alt'), + }; + }); + }, + company: function () { + return $('html').attr('data-oe-company-name'); + }, + bodyText: function () { + return $('body').children().not('.oe_seo_configuration').text(); + }, + heading1: function () { + return $('body').children().not('.oe_seo_configuration').find('h1').text(); + }, + heading2: function () { + return $('body').children().not('.oe_seo_configuration').find('h2').text(); + }, + isInBody: function (text) { + return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.bodyText()); + }, + isInTitle: function (text) { + return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.title()); + }, + isInDescription: function (text) { + return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.description()); + }, + isInHeading1: function (text) { + return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.heading1()); + }, + isInHeading2: function (text) { + return new RegExp(WORD_SEPARATORS_REGEX + text + WORD_SEPARATORS_REGEX, 'gi').test(this.heading2()); + }, +}); + +var MetaTitleDescription = Widget.extend({ + // Form and preview for SEO meta title and meta description + // + // We only want to show an alert for "description too small" on those cases + // - at init and the description is not empty + // - we reached past the minimum and went back to it + // - focus out of the field + // Basically we don't want the too small alert when the field is empty and + // we start typing on it. + template: 'website.seo_meta_title_description', + xmlDependencies: ['/website/static/src/xml/website.seo.xml'], + events: { + 'input input[name=website_meta_title]': '_titleChanged', + 'input input[name=website_seo_name]': '_seoNameChanged', + 'input textarea[name=website_meta_description]': '_descriptionOnInput', + 'change textarea[name=website_meta_description]': '_descriptionOnChange', + }, + maxRecommendedDescriptionSize: 300, + minRecommendedDescriptionSize: 50, + showDescriptionTooSmall: false, + + /** + * @override + */ + init: function (parent, options) { + this.htmlPage = options.htmlPage; + this.canEditTitle = !!options.canEditTitle; + this.canEditDescription = !!options.canEditDescription; + this.canEditUrl = !!options.canEditUrl; + this.isIndexed = !!options.isIndexed; + this.seoName = options.seoName; + this.seoNameDefault = options.seoNameDefault; + this.seoNameHelp = options.seoNameHelp; + this.previewDescription = options.previewDescription; + this._super(parent, options); + }, + /** + * @override + */ + start: function () { + this.$title = this.$('input[name=website_meta_title]'); + this.$seoName = this.$('input[name=website_seo_name]'); + this.$seoNamePre = this.$('span.seo_name_pre'); + this.$seoNamePost = this.$('span.seo_name_post'); + this.$description = this.$('textarea[name=website_meta_description]'); + this.$warning = this.$('div#website_meta_description_warning'); + this.$preview = this.$('.js_seo_preview'); + + if (!this.canEditTitle) { + this.$title.attr('disabled', true); + } + if (!this.canEditDescription) { + this.$description.attr('disabled', true); + } + if (this.htmlPage.title().trim() !== this.htmlPage.defaultTitle.trim()) { + this.$title.val(this.htmlPage.title()); + } + if (this.htmlPage.description().trim() !== this.previewDescription) { + this.$description.val(this.htmlPage.description()); + } + + if (this.canEditUrl) { + this.previousSeoName = this.seoName; + this.$seoName.val(this.seoName); + this.$seoName.attr('placeholder', this.seoNameDefault); + // make slug editable with input group for static text + const splitsUrl = window.location.pathname.split(this.previousSeoName || this.seoNameDefault); + this.$seoNamePre.text(splitsUrl[0]); + this.$seoNamePost.text(splitsUrl.slice(-1)[0]); // at least the -id theorically + } + this._descriptionOnChange(); + }, + /** + * Get the current title + */ + getTitle: function () { + return this.$title.val().trim() || this.htmlPage.defaultTitle; + }, + /** + * Get the potential new url with custom seoName as slug. + I can differ after save if slug JS != slug Python, but it provide an idea for the preview + */ + getUrl: function () { + const path = window.location.pathname.replace( + this.previousSeoName || this.seoNameDefault, + (this.$seoName.length && this.$seoName.val() ? this.$seoName.val().trim() : this.$seoName.attr('placeholder')) + ); + return window.location.origin + path + }, + /** + * Get the current description + */ + getDescription: function () { + return this.getRealDescription() || this.previewDescription; + }, + /** + * Get the current description chosen by the user + */ + getRealDescription: function () { + return this.$description.val() || ''; + }, + /** + * @private + */ + _titleChanged: function () { + var self = this; + self._renderPreview(); + self.trigger('title-changed'); + }, + /** + * @private + */ + _seoNameChanged: function () { + var self = this; + // don't use _, because we need to keep trailing whitespace during edition + const slugified = this.$seoName.val().toString().toLowerCase() + .replace(/\s+/g, '-') // Replace spaces with - + .replace(/[^\w\-]+/g, '-') // Remove all non-word chars + .replace(/\-\-+/g, '-'); // Replace multiple - with single - + this.$seoName.val(slugified); + self._renderPreview(); + }, + /** + * @private + */ + _descriptionOnChange: function () { + this.showDescriptionTooSmall = true; + this._descriptionOnInput(); + }, + /** + * @private + */ + _descriptionOnInput: function () { + var length = this.getDescription().length; + + if (length >= this.minRecommendedDescriptionSize) { + this.showDescriptionTooSmall = true; + } else if (length === 0) { + this.showDescriptionTooSmall = false; + } + + if (length > this.maxRecommendedDescriptionSize) { + this.$warning.text(_t('Your description looks too long.')).show(); + } else if (this.showDescriptionTooSmall && length < this.minRecommendedDescriptionSize) { + this.$warning.text(_t('Your description looks too short.')).show(); + } else { + this.$warning.hide(); + } + + this._renderPreview(); + this.trigger('description-changed'); + }, + /** + * @private + */ + _renderPreview: function () { + var indexed = this.isIndexed; + var preview = ""; + if (indexed) { + preview = new Preview(this, { + title: this.getTitle(), + description: this.getDescription(), + url: this.getUrl(), + }); + } else { + preview = new Preview(this, { + description: _t("You have hidden this page from search results. It won't be indexed by search engines."), + }); + } + this.$preview.empty(); + preview.appendTo(this.$preview); + }, +}); + +var MetaKeywords = Widget.extend({ + // Form and table for SEO meta keywords + template: 'website.seo_meta_keywords', + xmlDependencies: ['/website/static/src/xml/website.seo.xml'], + events: { + 'keyup input[name=website_meta_keywords]': '_confirmKeyword', + 'click button[data-action=add]': '_addKeyword', + }, + + init: function (parent, options) { + this.htmlPage = options.htmlPage; + this._super(parent, options); + }, + start: function () { + var self = this; + this.$input = this.$('input[name=website_meta_keywords]'); + this.keywordList = new KeywordList(this, {htmlPage: this.htmlPage}); + this.keywordList.on('list-full', this, function () { + self.$input.attr({ + readonly: 'readonly', + placeholder: "Remove a keyword first" + }); + self.$('button[data-action=add]').prop('disabled', true).addClass('disabled'); + }); + this.keywordList.on('list-not-full', this, function () { + self.$input.removeAttr('readonly').attr('placeholder', ""); + self.$('button[data-action=add]').prop('disabled', false).removeClass('disabled'); + }); + this.keywordList.on('selected', this, function (word, language) { + self.keywordList.add(word, language); + }); + this.keywordList.on('content-updated', this, function (removed) { + self._updateTable(removed); + }); + return this.keywordList.insertAfter(this.$('.table thead')).then(function() { + self._getLanguages(); + self._updateTable(); + }); + }, + _addKeyword: function () { + var $language = this.$('select[name=seo_page_language]'); + var keyword = this.$input.val(); + var language = $language.val().toLowerCase(); + this.keywordList.add(keyword, language); + this.$input.val('').focus(); + }, + _confirmKeyword: function (e) { + if (e.keyCode === 13) { + this._addKeyword(); + } + }, + _getLanguages: function () { + var self = this; + var context; + this.trigger_up('context_get', { + callback: function (ctx) { + context = ctx; + }, + }); + this._rpc({ + route: '/website/get_languages', + }).then(function (data) { + self.$('#language-box').html(core.qweb.render('Configurator.language_promote', { + 'language': data, + 'def_lang': context.lang + })); + }); + }, + /* + * Show the table if there is at least one keyword. Hide it otherwise. + * + * @private + * @param {boolean} removed: a keyword is about to be removed, + * we need to exclude it from the count + */ + _updateTable: function (removed) { + var min = removed ? 1 : 0; + if (this.keywordList.keywords().length > min) { + this.$('table').show(); + } else { + this.$('table').hide(); + } + }, +}); + +var MetaImageSelector = Widget.extend({ + template: 'website.seo_meta_image_selector', + xmlDependencies: ['/website/static/src/xml/website.seo.xml'], + events: { + 'click .o_meta_img_upload': '_onClickUploadImg', + 'click .o_meta_img': '_onClickSelectImg', + }, + /** + * @override + * @param {widget} parent + * @param {Object} data + */ + init: function (parent, data) { + this.metaTitle = data.title || ''; + this.activeMetaImg = data.metaImg; + this.serverUrl = data.htmlpage.url(); + const imgField = data.hasSocialDefaultImage ? 'social_default_image' : 'logo'; + data.pageImages.unshift(_.str.sprintf('/web/image/website/%s/%s', odoo.session_info.website_id, imgField)); + this.images = _.uniq(data.pageImages); + this.customImgUrl = _.contains( + data.pageImages.map((img)=> new URL(img, window.location.origin).pathname), + new URL(data.metaImg, window.location.origin).pathname) + ? false : data.metaImg; + this.previewDescription = data.previewDescription; + this._setDescription(this.previewDescription); + this._super(parent); + }, + setTitle: function (title) { + this.metaTitle = title; + this._updateTemplateBody(); + }, + setDescription: function (description) { + this._setDescription(description); + this._updateTemplateBody(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Set the description, applying ellipsis if too long. + * + * @private + */ + _setDescription: function (description) { + this.metaDescription = description || this.previewDescription; + if (this.metaDescription.length > 160) { + this.metaDescription = this.metaDescription.substring(0, 159) + '…'; + } + }, + + /** + * Update template. + * + * @private + */ + _updateTemplateBody: function () { + this.$el.empty(); + this.images = _.uniq(this.images); + this.$el.append(core.qweb.render('website.og_image_body', {widget: this})); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a select image from list -> change the preview accordingly. + * + * @private + * @param {MouseEvent} ev + */ + _onClickSelectImg: function (ev) { + var $img = $(ev.currentTarget); + this.activeMetaImg = $img.find('img').attr('src'); + this._updateTemplateBody(); + }, + /** + * Open a mediaDialog to select/upload image. + * + * @private + * @param {MouseEvent} ev + */ + _onClickUploadImg: function (ev) { + var self = this; + var $image = $('<img/>'); + var mediaDialog = new weWidgets.MediaDialog(this, { + onlyImages: true, + res_model: 'ir.ui.view', + }, $image[0]); + mediaDialog.open(); + mediaDialog.on('save', this, function (image) { + self.activeMetaImg = image.src; + self.customImgUrl = image.src; + self._updateTemplateBody(); + }); + }, +}); + +var SeoConfigurator = Dialog.extend({ + template: 'website.seo_configuration', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/website/static/src/xml/website.seo.xml'] + ), + canEditTitle: false, + canEditDescription: false, + canEditKeywords: false, + canEditLanguage: false, + canEditUrl: false, + + init: function (parent, options) { + options = options || {}; + _.defaults(options, { + title: _t('Optimize SEO'), + buttons: [ + {text: _t('Save'), classes: 'btn-primary', click: this.update}, + {text: _t('Discard'), close: true}, + ], + }); + + this._super(parent, options); + }, + start: function () { + var self = this; + + this.$modal.addClass('oe_seo_configuration'); + + this.htmlPage = new HtmlPage(); + + this.disableUnsavableFields().then(function () { + // Image selector + self.metaImageSelector = new MetaImageSelector(self, { + htmlpage: self.htmlPage, + hasSocialDefaultImage: self.hasSocialDefaultImage, + title: self.htmlPage.getOgMeta().metaTitle, + metaImg: self.metaImg || self.htmlPage.getOgMeta().ogImageUrl, + pageImages: _.pluck(self.htmlPage.images().get(), 'src'), + previewDescription: _t('The description will be generated by social media based on page content unless you specify one.'), + }); + self.metaImageSelector.appendTo(self.$('.js_seo_image')); + + // title and description + self.metaTitleDescription = new MetaTitleDescription(self, { + htmlPage: self.htmlPage, + canEditTitle: self.canEditTitle, + canEditDescription: self.canEditDescription, + canEditUrl: self.canEditUrl, + isIndexed: self.isIndexed, + previewDescription: _t('The description will be generated by search engines based on page content unless you specify one.'), + seoNameHelp: _t('This value will be escaped to be compliant with all major browsers and used in url. Keep it empty to use the default name of the record.'), + seoName: self.seoName, // 'my-custom-display-name' or '' + seoNameDefault: self.seoNameDefault, // 'display-name' + }); + self.metaTitleDescription.on('title-changed', self, self.titleChanged); + self.metaTitleDescription.on('description-changed', self, self.descriptionChanged); + self.metaTitleDescription.appendTo(self.$('.js_seo_meta_title_description')); + + // keywords + self.metaKeywords = new MetaKeywords(self, {htmlPage: self.htmlPage}); + self.metaKeywords.appendTo(self.$('.js_seo_meta_keywords')); + }); + }, + /* + * Reset meta tags to their initial value if not saved. + * + * @private + */ + destroy: function () { + if (!this.savedData) { + this.htmlPage.changeTitle(this.htmlPage.initTitle); + this.htmlPage.changeDescription(this.htmlPage.initDescription); + } + this._super.apply(this, arguments); + }, + disableUnsavableFields: function () { + var self = this; + return this.loadMetaData().then(function (data) { + // We only need a reload for COW when the copy is happening, therefore: + // - no reload if we are not editing a view (condition: website_id === undefined) + // - reload if generic page (condition: website_id === false) + self.reloadOnSave = data.website_id === undefined ? false : !data.website_id; + //If website.page, hide the google preview & tell user his page is currently unindexed + self.isIndexed = (data && ('website_indexed' in data)) ? data.website_indexed : true; + self.canEditTitle = data && ('website_meta_title' in data); + self.canEditDescription = data && ('website_meta_description' in data); + self.canEditKeywords = data && ('website_meta_keywords' in data); + self.metaImg = data.website_meta_og_img; + self.hasSocialDefaultImage = data.has_social_default_image; + self.canEditUrl = data && ('seo_name' in data); + self.seoName = self.canEditUrl && data.seo_name; + self.seoNameDefault = self.canEditUrl && data.seo_name_default; + if (!self.canEditTitle && !self.canEditDescription && !self.canEditKeywords) { + // disable the button to prevent an error if the current page doesn't use the mixin + // we make the check here instead of on the view because we don't need to check + // at every page load, just when the rare case someone clicks on this link + // TODO don't show the modal but just an alert in this case + self.$footer.find('button[data-action=update]').attr('disabled', true); + } + }); + }, + update: function () { + var self = this; + var data = {}; + if (this.canEditTitle) { + data.website_meta_title = this.metaTitleDescription.$title.val(); + } + if (this.canEditDescription) { + data.website_meta_description = this.metaTitleDescription.$description.val(); + } + if (this.canEditKeywords) { + data.website_meta_keywords = this.metaKeywords.keywordList.keywords().join(', '); + } + if (this.canEditUrl) { + if (this.metaTitleDescription.$seoName.val() != this.metaTitleDescription.previousSeoName) { + data.seo_name = this.metaTitleDescription.$seoName.val(); + self.reloadOnSave = true; // will force a refresh on old url and redirect to new slug + } + } + data.website_meta_og_img = this.metaImageSelector.activeMetaImg; + this.saveMetaData(data).then(function () { + // We want to reload if we are editing a generic page + // because it will become a specific page after this change (COW) + // and we want the user to be on the page he just created. + if (self.reloadOnSave) { + window.location.href = self.htmlPage.url(); + } else { + self.htmlPage.changeKeywords(self.metaKeywords.keywordList.keywords()); + self.savedData = true; + self.close(); + } + }); + }, + getMainObject: function () { + var mainObject; + this.trigger_up('main_object_request', { + callback: function (value) { + mainObject = value; + }, + }); + return mainObject; + }, + getSeoObject: function () { + var seoObject; + this.trigger_up('seo_object_request', { + callback: function (value) { + seoObject = value; + }, + }); + return seoObject; + }, + loadMetaData: function () { + var obj = this.getSeoObject() || this.getMainObject(); + return new Promise(function (resolve, reject) { + if (!obj) { + // return Promise.reject(new Error("No main_object was found.")); + resolve(null); + } else { + rpc.query({ + route: "/website/get_seo_data", + params: { + 'res_id': obj.id, + 'res_model': obj.model, + }, + }).then(function (data) { + var meta = data; + meta.model = obj.model; + resolve(meta); + }).guardedCatch(reject); + } + }); + }, + saveMetaData: function (data) { + var obj = this.getSeoObject() || this.getMainObject(); + if (!obj) { + return Promise.reject(); + } else { + return this._rpc({ + model: obj.model, + method: 'write', + args: [[obj.id], data], + }); + } + }, + titleChanged: function () { + var self = this; + _.defer(function () { + var title = self.metaTitleDescription.getTitle(); + self.htmlPage.changeTitle(title); + self.metaImageSelector.setTitle(title); + }); + }, + descriptionChanged: function () { + var self = this; + _.defer(function () { + var description = self.metaTitleDescription.getRealDescription(); + self.htmlPage.changeDescription(description); + self.metaImageSelector.setDescription(description); + }); + }, +}); + +var SeoMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ + actions: _.extend({}, websiteNavbarData.WebsiteNavbarActionWidget.prototype.actions || {}, { + 'promote-current-page': '_promoteCurrentPage', + }), + + init: function (parent, options) { + this._super(parent, options); + + if ($.deparam.querystring().enable_seo !== undefined) { + this._promoteCurrentPage(); + } + }, + + //-------------------------------------------------------------------------- + // Actions + //-------------------------------------------------------------------------- + + /** + * Opens the SEO configurator dialog. + * + * @private + */ + _promoteCurrentPage: function () { + new SeoConfigurator(this).open(); + }, +}); + +websiteNavbarData.websiteNavbarRegistry.add(SeoMenu, '#promote-menu'); + +return { + SeoConfigurator: SeoConfigurator, + SeoMenu: SeoMenu, +}; +}); diff --git a/addons/website/static/src/js/menu/translate.js b/addons/website/static/src/js/menu/translate.js new file mode 100644 index 00000000..afb2aff2 --- /dev/null +++ b/addons/website/static/src/js/menu/translate.js @@ -0,0 +1,88 @@ +odoo.define('website.translateMenu', function (require) { +'use strict'; + +var utils = require('web.utils'); +var TranslatorMenu = require('website.editor.menu.translate'); +var websiteNavbarData = require('website.navbar'); + +var TranslatePageMenu = websiteNavbarData.WebsiteNavbarActionWidget.extend({ + assetLibs: ['web_editor.compiled_assets_wysiwyg', 'website.compiled_assets_wysiwyg'], + + actions: _.extend({}, websiteNavbarData.WebsiteNavbar.prototype.actions || {}, { + edit_master: '_goToMasterPage', + translate: '_startTranslateMode', + }), + + /** + * @override + */ + start: function () { + var context; + this.trigger_up('context_get', { + extra: true, + callback: function (ctx) { + context = ctx; + }, + }); + this._mustEditTranslations = context.edit_translations; + if (this._mustEditTranslations) { + var url = window.location.href.replace(/([?&])&*edit_translations[^&#]*&?/, '\$1'); + window.history.replaceState({}, null, url); + + this._startTranslateMode(); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Actions + //-------------------------------------------------------------------------- + + /** + * Redirects the user to the same page but in the original language and in + * edit mode. + * + * @private + * @returns {Promise} + */ + _goToMasterPage: function () { + var current = document.createElement('a'); + current.href = window.location.toString(); + current.search += (current.search ? '&' : '?') + 'enable_editor=1'; + // we are in translate mode, the pathname starts with '/<url_code/' + current.pathname = current.pathname.substr(Math.max(0, current.pathname.indexOf('/', 1))); + + var link = document.createElement('a'); + link.href = '/website/lang/default'; + link.search += (link.search ? '&' : '?') + 'r=' + encodeURIComponent(current.pathname + current.search + current.hash); + + window.location = link.href; + return new Promise(function () {}); + }, + /** + * Redirects the user to the same page in translation mode (or start the + * translator is translation mode is already enabled). + * + * @private + * @returns {Promise} + */ + _startTranslateMode: function () { + if (!this._mustEditTranslations) { + window.location.search += '&edit_translations'; + return new Promise(function () {}); + } + + var translator = new TranslatorMenu(this); + + // We don't want the BS dropdown to close + // when clicking in a element to translate + $('.dropdown-menu').on('click', '.o_editable', function (ev) { + ev.stopPropagation(); + }); + + return translator.prependTo(document.body); + }, +}); + +websiteNavbarData.websiteNavbarRegistry.add(TranslatePageMenu, '.o_menu_systray:has([data-action="translate"])'); +}); |
