diff --git a/addons/web_editor/static/src/scss/web_editor.common.scss b/addons/web_editor/static/src/scss/web_editor.common.scss index d292b94a1a2..321a0a3bb01 100644 --- a/addons/web_editor/static/src/scss/web_editor.common.scss +++ b/addons/web_editor/static/src/scss/web_editor.common.scss @@ -287,7 +287,11 @@ ul.oe_menu_editor { list-style: none; } li div { - cursor: move; + @include o-grab-cursor; + + :active { + cursor: grabbing; + } } } diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 4d62e453d21..fdc8bfc5acc 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -174,8 +174,8 @@ 'website/static/src/client_actions/*/*.scss', 'website/static/src/services/website_service.js', 'website/static/src/components/navbar/navbar.js', - 'website/static/src/components/dialog/dialog.js', - 'website/static/src/components/dialog/dialog.scss', + 'website/static/src/components/dialog/*.js', + 'website/static/src/components/dialog/*.scss', 'website/static/src/components/switch/switch.js', 'website/static/src/components/switch/switch.scss', 'website/static/src/components/fullscreen_indication/fullscreen_indication.js', @@ -251,7 +251,7 @@ 'website/static/src/xml/website.backend.xml', 'website/static/src/xml/website_widget.xml', 'website/static/src/xml/theme_preview.xml', - 'website/static/src/components/dialog/dialog.xml', + 'website/static/src/components/dialog/*', 'website/static/src/client_actions/*/*.xml', 'website/static/src/systray_items/*.xml', ], diff --git a/addons/website/static/src/components/dialog/edit_menu.js b/addons/website/static/src/components/dialog/edit_menu.js index 6cb7809f507..bff6f86d96c 100644 --- a/addons/website/static/src/components/dialog/edit_menu.js +++ b/addons/website/static/src/components/dialog/edit_menu.js @@ -1,1139 +1,188 @@ -/** @odoo-module alias=website.contentMenu */ +/** @odoo-module **/ -import Class from 'web.Class'; -import core from 'web.core'; -import Dialog from 'web.Dialog'; -import time from 'web.time'; -import weWidgets from 'wysiwyg.widgets'; -import websiteNavbarData from 'website.navbar'; -import Widget from 'web.Widget'; -import { Markup } from 'web.utils'; +import { useService } from '@web/core/utils/hooks'; +import { WebsiteDialog } from './dialog'; -import { registry } from "@web/core/registry"; +const { Component, useState, useEffect, onWillStart, useRef, onMounted } = owl; -var _t = core._t; -var qweb = core.qweb; +class MenuDialog extends WebsiteDialog { + setup() { + super.setup(); -var PagePropertiesDialog = weWidgets.Dialog.extend({ - template: 'website.pagesMenu.page_info', - xmlDependencies: weWidgets.Dialog.prototype.xmlDependencies.concat( - ['/website/static/src/components/dialog/page_properties.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', - }), + this.title = this.env._t("Add a menu item"); - /** - * @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); - }, + this.state = useState({ + name: this.props.name, + url: this.props.url, }); - 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; - })); + primaryClick() { + this.props.save(this.state.name, this.state.url); + this.close(); + } +} +MenuDialog.bodyTemplate = 'website.MenuDialog'; +MenuDialog.props = { + ...WebsiteDialog.props, + name: { type: String, optional: true }, + url: { type: String, optional: true }, + isMegaMenu: { type: Boolean, optional: true }, + save: Function, +}; +MenuDialog.defaultProps = { + name: '', + url: '/', +}; - return Promise.all(defs); - }, - /** - * @override - */ - start: function () { - var self = this; +class MenuRow extends Component { + edit() { + this.props.edit(this.props.menu.fields['id']); + } - var defs = [this._super.apply(this, arguments)]; + delete() { + this.props.delete(this.props.menu.fields['id']); + } +} +MenuRow.props = { + menu: Object, + edit: Function, + delete: Function, +}; +MenuRow.template = 'website.MenuRow'; +MenuRow.components = { + MenuRow, +}; - 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')); +export class EditMenuDialog extends WebsiteDialog { + setup() { + super.setup(); + this.orm = useService('orm'); + this.website = useService('website'); + this.dialogs = useService('dialog'); - 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.$('a.o_dependencies_redirect_link').on('click', () => { - self.$('.o_dependencies_redirect_list_popover').popover({ - html: true, - title: _t('Dependencies'), - boundary: 'viewport', - placement: 'right', - trigger: 'focus', - content: () => { - return qweb.render('website.get_tooltip_dependencies', { - dependencies: dependencies, - }); - }, - template: qweb.render('website.page_dependencies_popover'), - }).popover('toggle'); - }); - })); + this.title = this.env._t("Edit Menu"); + this.primaryTitle = this.env._t("Save"); - defs.push(this._getSupportedMimetype() - .then(function (mimetypes) { - self.supportedMimetype = mimetypes; - })); + this.menuEditor = useRef('menu-editor'); - 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', - }); - })); + this.state = useState({ rootMenu: {} }); - 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} - */ - _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} - */ - _getPageKeyDependencies: function (moID) { - return this._rpc({ - model: 'website', - method: 'page_search_key_dependencies', - args: [moID], - }); - }, - /** - * Retrieves supported mimtype - * - * @private - * @returns {Promise} - */ - _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({ - /** - * @constructor - */ - init: function (parent, options, editable, data) { - this._super(parent, _.extend({ - title: _t("Add a menu item"), - }, options || {}), editable, _.extend({ - needLabel: true, - content: data.name || '', - isNewWindow: data.new_window, - }, data || {})); - - this.linkWidget.xmlDependencies = this.linkWidget.xmlDependencies.concat(['/website/static/src/components/dialog/edit_menu.xml']); - - const oldSave = this.linkWidget.save; - /** - * @override - */ - this.linkWidget.save = () => { - 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 Promise.reject(); - } - return oldSave.bind(this.linkWidget)(); - }; - - this.menuType = data.menuType; - }, - /** - * @override - */ - start: async function () { - const res = await this._super(...arguments); - - // 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 res; - }, -}); - -var SelectEditMenuDialog = weWidgets.Dialog.extend({ - template: 'website.contentMenu.dialog.select', - xmlDependencies: weWidgets.Dialog.prototype.xmlDependencies.concat( - ['/website/static/src/components/dialog/edit_menu.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/components/dialog/edit_menu.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; - // For the EditMenuDialog, saving the dialog should not close it, - // as a reload will be done to show the updated menus. - this.closeOnSave = false; - }, - /** - * @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); + onWillStart(async () => { + const menu = await this.orm.call('website.menu', 'get_tree', [this.website.currentWebsite.id, this.props.rootID]); + this.state.rootMenu = menu; + this.map = new Map(); + this.populate(this.map, this.state.rootMenu); 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; + }); + + onMounted(() => { + this.$sortables = $(this.menuEditor.el); + this.$sortables.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.isMegaMenu && !placeholderParent[0].dataset.isMegaMenu; + }, + }); + }); + } + + populate(map, menu) { + map.set(menu.fields['id'], menu); + for (const submenu of menu.children) { + this.populate(map, submenu); + } + } + + addMenu(isMegaMenu) { + this.dialogs.add(MenuDialog, { + isMegaMenu, + save: (name, url, isNewWindow) => { + const newMenu = { + fields: { + id: `menu_${(new Date).toISOString()}`, + name, + url: isMegaMenu ? '#' : url, + new_window: isNewWindow, + 'is_mega_menu': isMegaMenu, + sequence: 0, + 'parent_id': false, + }, + 'children': [], + 'is_homepage': false, + }; + this.map.set(newMenu.fields['id'], newMenu); + this.state.rootMenu.children.push(newMenu); }, }); - 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; + editMenu(id) { + const menuToEdit = this.map.get(id); + this.dialogs.add(MenuDialog, { + name: menuToEdit.fields['name'], + url: menuToEdit.fields['url'], + isMegaMenu: menuToEdit.fields['is_mega_menu'], + save: (name, url) => { + menuToEdit.fields['name'] = name; + menuToEdit.fields['url'] = url; + // Forces a rerender + this.state.rootMenu.children = [...this.state.rootMenu.children]; }, }); + } + + deleteMenu(id) { + const menuToDelete = this.map.get(id); + const parentId = menuToDelete.fields['parent_id'] || this.state.rootMenu.fields['id']; + const parent = this.map.get(parentId); + parent.children = parent.children.filter(menu => menu.fields['id'] !== id); + this.map.delete(id); + this.toDelete.push(id); + } + + async primaryClick() { + const newMenus = this.$sortables.nestedSortable('toArray', {startDepthCount: 0}); + const levels = []; + const data = []; + // Resequence, re-tree and remove useless data - newMenus.forEach(menu => { + for (const menu of newMenus) { if (menu.id) { levels[menu.depth] = (levels[menu.depth] || 0) + 1; - var menuFields = this.flat[menu.id].fields; + const {fields: menuFields} = this.map.get(menu.id) || this.map.get(parseInt(menu.id, 10)); menuFields['sequence'] = levels[menu.depth]; // JQuery's nestedSortable() extracts parent_ids as string. // They must be ints when they are actual existing ids but // remain as strings when they represent a creation ("new-##"). - menuFields['parent_id'] = parseInt(menu['parent_id']) || menu['parent_id'] || this.rootMenuID; + menuFields['parent_id'] = parseInt(menu['parent_id']) || menu['parent_id'] || this.state.rootMenu.fields['id']; data.push(menuFields); } - }); - return this._rpc({ - model: 'website.menu', - method: 'save', - args: [ - context.website_id, - { - 'data': data, - 'to_delete': this.toDelete, - } - ], - }).then(() => { - return _super(...arguments); - }); - }, - - //-------------------------------------------------------------------------- - // 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.content), - '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.content), - '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; + await this.orm.call('website.menu', 'save', [ + this.website.currentWebsite.id, + { + 'data': data, + 'to_delete': this.toDelete, } - 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} - */ - _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 -async function _deletePage(pageId, fromPageManagement) { - const dependencies = await this._getPageDependencies(pageId); - for (const locs of Object.values(dependencies)) { - for (const loc of locs) { - loc.text = Markup(loc.text); - } + ]); + this.close(); + this.website.goToWebsite(); } - Dialog.safeConfirm(this, "", { - title: _t("Delete Page"), - $content: $(qweb.render('website.delete_page', {dependencies: dependencies})), - async confirm_callback() { - await this._rpc({model: 'website.page', method: 'unlink', args: [pageId]}); - if (fromPageManagement) { - window.location.reload(true); - } else { - window.location.href = '/'; - } - } - }); } -/** - * 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); - }); -} - -registry.category("website_navbar_widgets").add("ContentMenu", { - Widget: ContentMenu, - selector: '#content-menu', -}); -registry.category("public_root_widgets").add("PageManagement", { - Widget: PageManagement, - selector: '#list_website_pages', -}); - -export default { - PagePropertiesDialog: PagePropertiesDialog, - ContentMenu: ContentMenu, - EditMenuDialog: EditMenuDialog, - MenuEntryDialog: MenuEntryDialog, - SelectEditMenuDialog: SelectEditMenuDialog, +EditMenuDialog.bodyTemplate = 'website.EditMenuDialog'; +EditMenuDialog.components = { + MenuRow, }; diff --git a/addons/website/static/src/components/dialog/edit_menu.xml b/addons/website/static/src/components/dialog/edit_menu.xml index af318703b5c..18aefa84fe7 100644 --- a/addons/website/static/src/components/dialog/edit_menu.xml +++ b/addons/website/static/src/components/dialog/edit_menu.xml @@ -1,59 +1,66 @@ - - - -
  • + + + +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + + +
  • - + - Mega Menu - + Mega Menu + -
    - -
      - - +
        + +
      -
      - -
      -
      - -
      +
      diff --git a/addons/website/static/src/components/navbar/navbar.js b/addons/website/static/src/components/navbar/navbar.js index 9f63fc28ff3..9b01c18099e 100644 --- a/addons/website/static/src/components/navbar/navbar.js +++ b/addons/website/static/src/components/navbar/navbar.js @@ -4,6 +4,7 @@ import { NavBar } from '@web/webclient/navbar/navbar'; import { useService, useBus } from '@web/core/utils/hooks'; import { registry } from "@web/core/registry"; import { patch } from 'web.utils'; +import { EditMenuDialog } from '@website/components/dialog/edit_menu'; const websiteSystrayRegistry = registry.category('website_systray'); @@ -11,6 +12,7 @@ patch(NavBar.prototype, 'website_navbar', { setup() { this._super(); this.websiteService = useService('website'); + this.dialogService = useService('dialog'); // The navbar is rerendered with an event, as it can not naturally be // with props/state (the WebsitePreview client action and the navbar @@ -22,6 +24,24 @@ patch(NavBar.prototype, 'website_navbar', { } useBus(websiteSystrayRegistry, 'CONTENT-UPDATED', () => this.render(true)); + + this.websiteDialogMenus = { + 'website.menu_edit_menu': EditMenuDialog, + }; + }, + + filterWebsiteMenus(sections) { + const filteredSections = []; + for (const section of sections) { + if (!this.websiteDialogMenus[section.xmlid]) { + let subSections = []; + if (section.childrenTree.length) { + subSections = this.filterWebsiteMenus(section.childrenTree); + } + filteredSections.push(Object.assign({}, section, {childrenTree: subSections})); + } + } + return filteredSections; }, /** @@ -37,4 +57,25 @@ patch(NavBar.prototype, 'website_navbar', { } return this._super(); }, + + /** + * @override + */ + get currentAppSections() { + const currentAppSections = this._super(); + if (this.currentApp && this.currentApp.xmlid === 'website.menu_website_configuration' && !this.websiteService.currentWebsite) { + return this.filterWebsiteMenus(currentAppSections).filter(section => section.childrenTree.length); + } + return currentAppSections; + }, + + /** + * @override + */ + onNavBarDropdownItemSelection(menu) { + if (this.websiteDialogMenus[menu.xmlid]) { + return this.dialogService.add(this.websiteDialogMenus[menu.xmlid]); + } + return this._super(menu); + } }); diff --git a/addons/website/static/src/services/website_service.js b/addons/website/static/src/services/website_service.js index 0f8e1a81ad2..1170d8664af 100644 --- a/addons/website/static/src/services/website_service.js +++ b/addons/website/static/src/services/website_service.js @@ -68,7 +68,7 @@ export const websiteService = { get context() { return context; }, - goToWebsite({ websiteId = currentWebsiteId || websites[0].id, path = '/' }) { + goToWebsite({ websiteId = currentWebsiteId || websites[0].id, path = '/' } = {}) { action.doAction('website.website_preview', { clearBreadcrumbs: true, additionalContext: { diff --git a/addons/website/views/website_views.xml b/addons/website/views/website_views.xml index 689b5d1dde8..2bb3b0c0c22 100644 --- a/addons/website/views/website_views.xml +++ b/addons/website/views/website_views.xml @@ -552,6 +552,12 @@ name="Pages" action="action_website_pages_list"/> + +