summaryrefslogtreecommitdiff
path: root/addons/website/static/src/js/menu
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/website/static/src/js/menu
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website/static/src/js/menu')
-rw-r--r--addons/website/static/src/js/menu/content.js1129
-rw-r--r--addons/website/static/src/js/menu/customize.js219
-rw-r--r--addons/website/static/src/js/menu/debug_manager.js21
-rw-r--r--addons/website/static/src/js/menu/edit.js256
-rw-r--r--addons/website/static/src/js/menu/mobile_view.js68
-rw-r--r--addons/website/static/src/js/menu/navbar.js292
-rw-r--r--addons/website/static/src/js/menu/new_content.js350
-rw-r--r--addons/website/static/src/js/menu/seo.js902
-rw-r--r--addons/website/static/src/js/menu/translate.js88
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"])');
+});