From 19eacf7d23c9413de4430a3422b5ed74b37ef242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Mon, 17 Sep 2018 12:28:39 +0200 Subject: [PATCH] [IMP] web: new navbar in community The navbar in community now looks more like the navbar in enterprise. Here is a sum-up of the changes in community: - The navbar in community is now in blue, instead of black. - No more app list that almost fills the whole navbar: there is now an "Apps Menu" in the left-most side of the navbar. - No more sidebar for the menus inside an app: those menus are now located in the middle part of the navbar. The navbar in community does not support mobile (< 768px width), but it should still somewhat usable on most mobile devices. As for the website navbar, it has been unchanged and should still support mobile layout. Task-ID 1878649 --- addons/web/__manifest__.py | 1 + addons/web/static/src/js/chrome/apps_menu.js | 104 +++++ addons/web/static/src/js/chrome/menu.js | 334 +++++++++------- addons/web/static/src/js/chrome/web_client.js | 365 +++++++++++------- .../web/static/src/js/tools/debug_manager.js | 2 +- addons/web/static/src/js/tools/test_menus.js | 9 +- addons/web/static/src/scss/navbar.scss | 87 +---- addons/web/static/src/scss/navbar_mobile.scss | 95 +++++ .../static/src/scss/primary_variables.scss | 2 +- .../web/static/src/scss/webclient_extra.scss | 12 +- .../web/static/src/scss/webclient_layout.scss | 238 ++++-------- addons/web/static/src/xml/base.xml | 2 +- addons/web/static/src/xml/menu.xml | 225 +++++++++++ addons/web/views/webclient_templates.xml | 75 +--- 14 files changed, 926 insertions(+), 625 deletions(-) create mode 100644 addons/web/static/src/js/chrome/apps_menu.js create mode 100644 addons/web/static/src/scss/navbar_mobile.scss create mode 100644 addons/web/static/src/xml/menu.xml diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index ea15b2c22df..bbfe3a134a0 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -24,6 +24,7 @@ This module provides the core of the Odoo Web Client. 'qweb': [ "static/src/xml/base.xml", "static/src/xml/kanban.xml", + "static/src/xml/menu.xml", "static/src/xml/rainbow_man.xml", "static/src/xml/report.xml", "static/src/xml/web_calendar.xml", diff --git a/addons/web/static/src/js/chrome/apps_menu.js b/addons/web/static/src/js/chrome/apps_menu.js new file mode 100644 index 00000000000..bdc330b0e29 --- /dev/null +++ b/addons/web/static/src/js/chrome/apps_menu.js @@ -0,0 +1,104 @@ +odoo.define('web.AppsMenu', function (require) { +"use strict"; + +var Widget = require('web.Widget'); + +var AppsMenu = Widget.extend({ + template: 'AppsMenu', + events: { + 'click .o_app': '_onAppsMenuItemClicked', + }, + /** + * @override + * @param {web.Widget} parent + * @param {Object} menuData + * @param {Object[]} menuData.children + */ + init: function (parent, menuData) { + this._super.apply(this, arguments); + this._activeApp = undefined; + this._apps = _.map(menuData.children, function (appMenuData) { + return { + actionID: parseInt(appMenuData.action.split(',')[1]), + menuID: appMenuData.id, + name: appMenuData.name, + xmlID: appMenuData.xmlid, + }; + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {Object} + */ + getActiveApp: function () { + return this._activeApp || {}; + }, + /** + * @returns {Object[]} + */ + getApps: function () { + return this._apps; + }, + /** + * Open the given app + */ + openApp: function () {}, + /** + * Open the first app in the list of apps + */ + openFirstApp: function () { + var firstApp = this._apps[0]; + this._openApp(firstApp); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} app + */ + _openApp: function (app) { + this._setActiveApp(app); + this.trigger_up('app_clicked', { + action_id: app.actionID, + menu_id: app.menuID, + }); + }, + /** + * @private + * @param {Object} app + */ + _setActiveApp: function (app) { + this._activeApp = app; + this.renderElement(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on an item in the apps menu. + * + * @private + * @param {MouseEvent} ev + */ + _onAppsMenuItemClicked: function (ev) { + var $target = $(ev.currentTarget); + var actionID = $target.data('action-id'); + var menuID = $target.data('menu-id'); + var app = _.findWhere(this._apps, { actionID: actionID, menuID: menuID }); + this._openApp(app); + }, + +}); + +return AppsMenu; + +}); diff --git a/addons/web/static/src/js/chrome/menu.js b/addons/web/static/src/js/chrome/menu.js index c96d4b17f8c..7f60e2ea44f 100644 --- a/addons/web/static/src/js/chrome/menu.js +++ b/addons/web/static/src/js/chrome/menu.js @@ -1,164 +1,140 @@ odoo.define('web.Menu', function (require) { "use strict"; +var AppsMenu = require('web.AppsMenu'); +var config = require('web.config'); var core = require('web.core'); var dom = require('web.dom'); -var session = require('web.session'); +var SystrayMenu = require('web.SystrayMenu'); +var UserMenu = require('web.UserMenu'); var Widget = require('web.Widget'); +UserMenu.prototype.sequence = 0; // force UserMenu to be the right-most item in the systray +SystrayMenu.Items.push(UserMenu); + +var QWeb = core.qweb; + var Menu = Widget.extend({ - init: function() { - this._super.apply(this, arguments); - this.is_bound = $.Deferred(); - this.data = {data:{children:[]}}; - core.bus.on('change_menu_section', this, this.on_change_top_menu); + template: 'Menu', + menusTemplate: 'Menu.sections', + events: { + 'mouseover .o_menu_sections > li:not(.show)': '_onMouseOverMenu', + 'click .o_menu_brand': '_onAppNameClicked', }, - start: function() { - this._super.apply(this, arguments); - return this.bind_menu(); - }, - do_reload: function() { + + init: function (parent, menu_data) { var self = this; - self.bind_menu(); - }, - bind_menu: function() { - var self = this; - this.$secondary_menus = this.$el.parents().find('.o_sub_menu'); - this.$secondary_menus.on('click', 'a[data-menu]', this.on_menu_click); - this.$el.on('click', 'a[data-menu]', function (event) { - event.preventDefault(); - var menu_id = $(event.currentTarget).data('menu'); - core.bus.trigger('change_menu_section', menu_id); + this._super.apply(this, arguments); + + this.$menu_sections = {}; + this.menu_data = menu_data; + + // Prepare navbar's menus + var $menu_sections = $(QWeb.render(this.menusTemplate, { + menu_data: this.menu_data, + })); + $menu_sections.filter('section').each(function () { + self.$menu_sections[parseInt(this.className, 10)] = $(this).children('li'); }); - // Hide second level submenus - this.$secondary_menus.find('.oe_menu_toggler').siblings('.oe_secondary_submenu').addClass('o_hidden'); - if (self.current_menu) { - self.open_menu(self.current_menu); - } - this.trigger('menu_bound'); - - dom.initAutoMoreMenu(this.$el); - - this.is_bound.resolve(); + // Bus event + core.bus.on('change_menu_section', this, this._onChangeMenuSection); }, - /** - * Opens a given menu by id, as if a user had browsed to that menu by hand - * except does not trigger any event on the way - * - * @param {Number} id database id of the terminal menu to select - */ - open_menu: function (id) { - this.current_menu = id; - session.active_id = id; - var $clicked_menu, $sub_menu, $main_menu; - $clicked_menu = this.$el.add(this.$secondary_menus).find('a[data-menu=' + id + ']'); - this.trigger('open_menu', id, $clicked_menu); - - if (this.$secondary_menus.has($clicked_menu).length) { - $sub_menu = $clicked_menu.parents('.oe_secondary_menu'); - $main_menu = this.$el.find('a[data-menu=' + $sub_menu.data('menu-parent') + ']'); - } else { - $sub_menu = this.$secondary_menus.find('.oe_secondary_menu[data-menu-parent=' + $clicked_menu.attr('data-menu') + ']'); - $main_menu = $clicked_menu; - } - - this.current_primary_menu = $main_menu.data('menu'); - - // Activate current main menu - this.$el.find('.active').removeClass('active'); - $main_menu.parent().addClass('active'); - - // Show current sub menu - this.$secondary_menus.find('.oe_secondary_menu').hide(); - $sub_menu.show(); - - // Hide/Show the leftbar menu depending of the presence of sub-items - this.$secondary_menus.toggleClass('o_hidden', !$sub_menu.children().length); - - // Activate current menu item and show parents - this.$secondary_menus.find('.active').removeClass('active'); - if ($main_menu !== $clicked_menu) { - $clicked_menu.parents().removeClass('o_hidden'); - if ($clicked_menu.is('.oe_menu_toggler')) { - $clicked_menu.toggleClass('oe_menu_opened').siblings('.oe_secondary_submenu:first').toggleClass('o_hidden'); - } else { - $clicked_menu.parent().addClass('active'); - } - } - // add a tooltip to cropped menu items - this.$secondary_menus.find('.oe_secondary_submenu li a span').each(function() { - $(this).tooltip(this.scrollWidth > this.clientWidth ? {title: $(this).text().trim(), placement: 'right'} : 'dispose'); - }); - }, - /** - * Call open_menu on a menu_item that matches the action_id - * - * If `menuID` is a match on this action, open this menu_item. - * Otherwise open the first menu_item that matches the action_id. - * - * @param {Number} id the action_id to match - * @param {Number} [menuID] a menu ID that may match with provided action - */ - open_action: function (id, menuID) { - var $menu = this.$el.add(this.$secondary_menus).find('a[data-action-id="' + id + '"]'); - if (!(menuID && $menu.filter("[data-menu='" + menuID + "']").length)) { - // menuID doesn't match action, so pick first menu_item - menuID = $menu.data('menu'); - } - if (menuID) { - this.open_menu(menuID); - } - }, - /** - * Process a click on a menu item - * - * @param {Number} id the menu_id - */ - menu_click: function(id) { - if (!id) { return; } - - // find back the menuitem in dom to get the action - var $item = this.$el.find('a[data-menu=' + id + ']'); - if (!$item.length) { - $item = this.$secondary_menus.find('a[data-menu=' + id + ']'); - } - var action_id = $item.data('action-id'); - // If first level menu doesnt have action trigger first leaf - if (!action_id) { - if(this.$el.has($item).length) { - var $sub_menu = this.$secondary_menus.find('.oe_secondary_menu[data-menu-parent=' + id + ']'); - var $items = $sub_menu.find('a[data-action-id]').filter('[data-action-id!=""]'); - if($items.length) { - action_id = $items.data('action-id'); - id = $items.data('menu'); - } - } - } - if (action_id) { - this.trigger('menu_click', { - action_id: action_id, - id: id, - previous_menu_id: this.current_menu // Here we don't know if action will fail (in which case we have to revert menu) - }, $item); - } else { - console.log('Menu no action found web test 04 will fail'); - } - this.open_menu(id); - }, - - /** - * Change the current top menu - * - * @param {int} [menu_id] the top menu id - */ - on_change_top_menu: function(menu_id) { + start: function () { var self = this; - this.menu_click(menu_id); + + this.$menu_apps = this.$('.o_menu_apps'); + this.$menu_brand_placeholder = this.$('.o_menu_brand'); + this.$section_placeholder = this.$('.o_menu_sections'); + + // Navbar's menus event handlers + var on_secondary_menu_click = function (ev) { + ev.preventDefault(); + var menu_id = $(ev.currentTarget).data('menu'); + var action_id = $(ev.currentTarget).data('action-id'); + self._on_secondary_menu_click(menu_id, action_id); + }; + var menu_ids = _.keys(this.$menu_sections); + var primary_menu_id, $section; + for (var i = 0; i < menu_ids.length; i++) { + primary_menu_id = menu_ids[i]; + $section = this.$menu_sections[primary_menu_id]; + $section.on('click', 'a[data-menu]', self, on_secondary_menu_click.bind(this)); + } + + // Apps Menu + this._appsMenu = new AppsMenu(self, this.menu_data); + this._appsMenu.appendTo(this.$menu_apps); + + // Systray Menu + this.systray_menu = new SystrayMenu(this); + this.systray_menu.attachTo(this.$('.o_menu_systray')); + + dom.initAutoMoreMenu(this.$section_placeholder, { + maxWidth: function () { + return self.$el.width() - (self.$menu_apps.outerWidth(true) + self.$menu_brand_placeholder.outerWidth(true) + self.systray_menu.$el.outerWidth(true)); + }, + sizeClass: 'SM', + }); + + return this._super.apply(this, arguments); }, - on_menu_click: function(ev) { - ev.preventDefault(); - this.menu_click($(ev.currentTarget).data('menu')); + _trigger_menu_clicked: function (menu_id, action_id) { + this.trigger_up('menu_clicked', { + id: menu_id, + action_id: action_id, + previous_menu_id: this.current_secondary_menu || this.current_primary_menu, + }); + }, + _on_secondary_menu_click: function (menu_id, action_id) { + var self = this; + + // It is still possible that we don't have an action_id (for example, menu toggler) + if (action_id) { + self._trigger_menu_clicked(menu_id, action_id); + this.current_secondary_menu = menu_id; + } + }, + /** + * Helpers used by web_client in order to restore the state from + * an url (by restore, read re-synchronize menu and action manager) + */ + action_id_to_primary_menu_id: function (action_id) { + var primary_menu_id, found; + for (var i = 0; i < this.menu_data.children.length && !primary_menu_id; i++) { + found = this._action_id_in_subtree(this.menu_data.children[i], action_id); + if (found) { + primary_menu_id = this.menu_data.children[i].id; + } + } + return primary_menu_id; + }, + _action_id_in_subtree: function (root, action_id) { + // action_id can be a string or an integer + if (root.action && root.action.split(',')[1] === String(action_id)) { + return true; + } + var found; + for (var i = 0; i < root.children.length && !found; i++) { + found = this._action_id_in_subtree(root.children[i], action_id); + } + return found; + }, + menu_id_to_action_id: function (menu_id, root) { + if (!root) { + root = $.extend(true, {}, this.menu_data); + } + + if (root.id === menu_id) { + return root.action.split(',')[1] ; + } + for (var i = 0; i < root.children.length; i++) { + var action_id = this.menu_id_to_action_id(menu_id, root.children[i]); + if (action_id !== undefined) { + return action_id; + } + } + return undefined; }, //-------------------------------------------------------------------------- @@ -173,7 +149,73 @@ var Menu = Widget.extend({ getCurrentPrimaryMenu: function () { return this.current_primary_menu; }, + /** + * Open the first app + */ + openFirstApp: function () { + this._appsMenu.openFirstApp(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When clicking on app name, opens the first action of the app + * + * @private + * @param {MouseEvent} ev + */ + _onAppNameClicked: function (ev) { + var actionID = parseInt(this.menu_id_to_action_id(this.current_primary_menu)); + this._trigger_menu_clicked(this.current_primary_menu, actionID); + }, + /** + * @private + * @param {integer} primaryMenuID + */ + _onChangeMenuSection: function (primaryMenuID) { + if (!this.$menu_sections[primaryMenuID]) { + return; // unknown menu_id + } + + if (this.current_primary_menu === primaryMenuID) { + return; // already in that menu + } + + if (this.current_primary_menu) { + this.$menu_sections[this.current_primary_menu].detach(); + } + + // Get back the application name + for (var i = 0; i < this.menu_data.children.length; i++) { + if (this.menu_data.children[i].id === primaryMenuID) { + this.$menu_brand_placeholder.text(this.menu_data.children[i].name); + break; + } + } + + this.$menu_sections[primaryMenuID].appendTo(this.$section_placeholder); + this.current_primary_menu = primaryMenuID; + + core.bus.trigger('resize'); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onMouseOverMenu: function (ev) { + if (config.device.isMobile) { + return; + } + var $target = $(ev.currentTarget); + var $opened = $target.siblings('.show'); + if ($opened.length) { + $target.find('[data-toggle="dropdown"]').dropdown('toggle'); + } + }, }); return Menu; + }); diff --git a/addons/web/static/src/js/chrome/web_client.js b/addons/web/static/src/js/chrome/web_client.js index ca777d470ae..8b725423d0f 100644 --- a/addons/web/static/src/js/chrome/web_client.js +++ b/addons/web/static/src/js/chrome/web_client.js @@ -6,149 +6,164 @@ var config = require('web.config'); var core = require('web.core'); var data_manager = require('web.data_manager'); var dom = require('web.dom'); -var framework = require('web.framework'); var Menu = require('web.Menu'); var session = require('web.session'); -var SystrayMenu = require('web.SystrayMenu'); -var UserMenu = require('web.UserMenu'); return AbstractWebClient.extend({ - events: _.extend({}, AbstractWebClient.prototype.events, { - 'click .oe_logo_edit_admin': 'logo_edit', - 'click .oe_logo img': function(ev) { - ev.preventDefault(); - return this.clear_uncommitted_changes().then(function() { - framework.redirect("/web" + (config.debug ? "?debug" : "")); - }); - }, + custom_events: _.extend({}, AbstractWebClient.prototype.custom_events, { + app_clicked: 'on_app_clicked', + menu_clicked: 'on_menu_clicked', }), - show_application: function() { - var self = this; - - this.toggle_bars(true); - this.set_title(); - this.update_logo(); - - // Menu is rendered server-side thus we don't want the widget to create any dom - this.menu = new Menu(this); - this.menu.setElement(this.$el.parents().find('.oe_application_menu_placeholder')); - this.menu.on('menu_click', this, this.on_menu_action); - - // Create the user menu (rendered client-side) - this.user_menu = new UserMenu(this); - var $user_menu_placeholder = $('body').find('.oe_user_menu_placeholder').show(); - var user_menu_loaded = this.user_menu.appendTo($user_menu_placeholder); - - // Create the systray menu (rendered server-side) - this.systray_menu = new SystrayMenu(this); - this.systray_menu.setElement(this.$el.parents().find('.oe_systray')); - var systray_menu_loaded = this.systray_menu.start(); - - // Start the menu once both systray and user menus are rendered - // to prevent overflows while loading - return $.when(systray_menu_loaded, user_menu_loaded).then(function() { - self.bind_hashchange(); - self.menu.$el.removeClass('d-none'); - self.menu.start(); + start: function () { + core.bus.on('change_menu_section', this, function (menuID) { + this.do_push_state(_.extend($.bbq.getState(), { + menu_id: menuID, + })); }); + return this._super.apply(this, arguments); }, - toggle_bars: function(value) { - this.$('tr:has(td.navbar),.oe_leftbar').toggle(value); - }, - update_logo: function(reload) { - var company = session.company_id; - var img = session.url('/web/binary/company_logo' + '?db=' + session.db + (company ? '&company=' + company : '')); - this.$('.o_sub_menu_logo img').attr('src', '').attr('src', img + (reload ? "&t=" + Date.now() : '')); - this.$('.oe_logo_edit').toggleClass('oe_logo_edit_admin', session.is_superuser); - }, - logo_edit: function(ev) { + bind_events: function () { var self = this; - ev.preventDefault(); - this._rpc({ - model: 'res.users', - method: 'read', - args: [[session.uid], ['company_id']], + this._super.apply(this, arguments); + + /* + Small patch to allow having a link with a href towards an anchor. Since odoo use hashtag + to represent the current state of the view, we can't easily distinguish between a link + towards an anchor and a link towards anoter view/state. If we want to navigate towards an + anchor, we must not change the hash of the url otherwise we will be redirected to the app + switcher instead. + To check if we have an anchor, first check if we have an href attributes starting with #. + Try to find a element in the DOM using JQuery selector. + If we have a match, it means that it is probably a link to an anchor, so we jump to that anchor. + */ + this.$el.on('click', 'a', function (ev) { + var disable_anchor = ev.target.attributes.disable_anchor; + if (disable_anchor && disable_anchor.value === "true") { + return; + } + + var href = ev.target.attributes.href; + if (href) { + if (href.value[0] === '#' && href.value.length > 1) { + if (self.$("[id='"+href.value.substr(1)+"']").length) { + ev.preventDefault(); + self.trigger_up('scrollTo', {'selector': href.value}); + } + } + } + }); + }, + load_menus: function () { + return this._rpc({ + model: 'ir.ui.menu', + method: 'load_menus', + args: [config.debug], + context: session.user_context, }) - .then(function(data) { - self._rpc({ - route: '/web/action/load', - params: { action_id: 'base.action_res_company_form' }, - }) - .done(function(result) { - result.res_id = data[0].company_id[0]; - result.target = "new"; - result.views = [[false, 'form']]; - result.flags = { - action_buttons: true, - headless: true, - }; - self.action_manager.doAction(result, { - on_close: self.update_logo.bind(self, true), - }); - }); + .then(function (menuData) { + // Compute action_id if not defined on a top menu item + for (var i = 0; i < menuData.children.length; i++) { + var child = menuData.children[i]; + if (child.action === false) { + while (child.children && child.children.length) { + child = child.children[0]; + if (child.action) { + menuData.children[i].action = child.action; + break; + } + } + } + } + return menuData; }); - return false; }, - bind_hashchange: function() { + show_application: function () { var self = this; - $(window).bind('hashchange', this.on_hashchange); - var didHashChanged = false; - $(window).one('hashchange', function () { - didHashChanged = true; - }); + this.set_title(); - var state = $.bbq.getState(true); - if (_.isEmpty(state) || state.action === "login") { - self.menu.is_bound.done(function() { - self._rpc({ + return this.instanciate_menu_widgets().then(function () { + $(window).bind('hashchange', self.on_hashchange); + + // If the url's state is empty, we execute the user's home action if there is one (we + // show the first app if not) + if (_.isEmpty($.bbq.getState(true))) { + return self._rpc({ model: 'res.users', method: 'read', - args: [[session.uid], ['action_id']], + args: [session.uid, ["action_id"]], }) - .done(function(result) { - var amDef = self.action_manager.dp.current_def; - if (didHashChanged || (amDef && amDef.state() === 'pending')) { - return; - } + .then(function (result) { var data = result[0]; - if(data.action_id) { - self.action_manager.doAction(data.action_id[0]); - self.menu.open_action(data.action_id[0]); + if (data.action_id) { + return self.do_action(data.action_id[0]).then(function () { + self.menu.change_menu_section(self.menu.action_id_to_primary_menu_id(data.action_id[0])); + }); } else { - var first_menu_id = self.menu.$el.find("a:first").data("menu"); - if(first_menu_id) { - self.menu.menu_click(first_menu_id); + self.menu.openFirstApp(); + } + }); + } else { + return self.on_hashchange(); + } + }); + }, + + instanciate_menu_widgets: function () { + var self = this; + var defs = []; + return this.load_menus().then(function (menuData) { + self.menu_data = menuData; + + // Here, we instanciate every menu widgets and we immediately append them into dummy + // document fragments, so that their `start` method are executed before inserting them + // into the DOM. + if (self.menu) { + self.menu.destroy(); + } + self.menu = new Menu(self, menuData); + defs.push(self.menu.prependTo(self.$el)); + return $.when.apply($, defs); + }); + }, + + // -------------------------------------------------------------- + // URL state handling + // -------------------------------------------------------------- + on_hashchange: function (event) { + if (this._ignore_hashchange) { + this._ignore_hashchange = false; + return $.when(); + } + + var self = this; + return this.clear_uncommitted_changes().then(function () { + var stringstate = $.bbq.getState(false); + if (!_.isEqual(self._current_state, stringstate)) { + var state = $.bbq.getState(true); + if (state.action || (state.model && (state.view_type || state.id))) { + return self.action_manager.loadState(state, !!self._current_state).then(function () { + if (state.menu_id) { + if (state.menu_id !== self.menu.current_primary_menu) { + core.bus.trigger('change_menu_section', state.menu_id); + } + } else { + var action = self.action_manager.getCurrentAction(); + if (action) { + var menu_id = self.menu.action_id_to_primary_menu_id(action.id); + if (menu_id) { + core.bus.trigger('change_menu_section', menu_id); + } } } }); - }); - } else { - $(window).trigger('hashchange'); - } - }, - on_hashchange: function(event) { - if (this._ignore_hashchange) { - this._ignore_hashchange = false; - return; - } - - var self = this; - this.clear_uncommitted_changes().then(function () { - var stringstate = event.getState(false); - if (!_.isEqual(self._current_state, stringstate)) { - var state = event.getState(true); - if(!state.action && state.menu_id) { - self.menu.is_bound.done(function() { - self.menu.menu_click(state.menu_id); + } else if (state.menu_id) { + var action_id = self.menu.menu_id_to_action_id(state.menu_id); + return self.do_action(action_id, {clear_breadcrumbs: true}).then(function () { + core.bus.trigger('change_menu_section', state.menu_id); }); } else { - self.action_manager.loadState(state, !!self._current_state).then(function () { - var action = self.action_manager.getCurrentAction(); - if (action) { - self.menu.open_action(action.id, state.menu_id); - } - }); + self.menu.openFirstApp(); } } self._current_state = stringstate; @@ -159,17 +174,88 @@ return AbstractWebClient.extend({ } }); }, - on_menu_action: function(options) { - this.action_manager.doAction(options.action_id, { - clear_breadcrumbs: true, - action_menu_id: options.id, - }); + + // -------------------------------------------------------------- + // Menu handling + // -------------------------------------------------------------- + on_app_clicked: function (ev) { + var self = this; + return this.menu_dm.add(data_manager.load_action(ev.data.action_id)) + .then(function (result) { + return self.action_mutex.exec(function () { + var completed = $.Deferred(); + var options = _.extend({}, ev.data.options, { + clear_breadcrumbs: true, + action_menu_id: ev.data.menu_id, + }); + $.when(self._openMenu(result, options)).fail(function () { + completed.resolve(); + }).done(function () { + self._on_app_clicked_done(ev) + .then(completed.resolve.bind(completed)) + .fail(completed.reject.bind(completed)); + }); + setTimeout(function () { + completed.resolve(); + }, 2000); + return completed; + }); + }); }, - toggle_fullscreen: function(fullscreen) { - this._super(fullscreen); - if (!fullscreen) { - core.bus.trigger('resize'); - } + _on_app_clicked_done: function (ev) { + core.bus.trigger('change_menu_section', ev.data.menu_id); + return $.Deferred().resolve(); + }, + on_menu_clicked: function (ev) { + var self = this; + return this.menu_dm.add(data_manager.load_action(ev.data.action_id)) + .then(function (result) { + return self.action_mutex.exec(function () { + var completed = $.Deferred(); + $.when(self._openMenu(result, { + clear_breadcrumbs: true, + })).always(function () { + completed.resolve(); + }); + + setTimeout(function () { + completed.resolve(); + }, 2000); + + return completed; + }); + }).always(function () { + self.$el.removeClass('o_mobile_menu_opened'); + }); + }, + /** + * Open the action linked to a menu. + * This function is mostly used to allow override in other modules. + * + * @private + * @param {Object} action + * @param {Object} options + * @returns {Deferred} + */ + _openMenu: function (action, options) { + return this.do_action(action, options); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the left and top scroll positions of the main scrolling area + * (i.e. the action manager in desktop). + * + * @returns {Object} with keys left and top + */ + getScrollPosition: function () { + return { + left: this.action_manager.el.scrollLeft, + top: this.action_manager.el.scrollTop, + }; }, //-------------------------------------------------------------------------- @@ -178,29 +264,22 @@ return AbstractWebClient.extend({ /** * @override + * @private */ _onGetScrollPosition: function (ev) { - ev.data.callback({ - left: this.action_manager.el.scrollLeft, - top: this.action_manager.el.scrollTop, - }); + ev.data.callback(this.getScrollPosition()); }, /** * @override + * @private */ _onScrollTo: function (ev) { - var offset; - if (ev.data.selector) { + var offset = {top: ev.data.top, left: ev.data.left || 0}; + if (!offset.top) { offset = dom.getPosition(document.querySelector(ev.data.selector)); - // substract the position of the ActionManager as it is the - // scrolling element - var actionManagerOffset = dom.getPosition(this.action_manager.el); - offset.left -= actionManagerOffset.left; - offset.top -= actionManagerOffset.top; - } else { - offset = {top: ev.data.top || 0, left: ev.data.left || 0}; + // Substract the position of the action_manager as it is the scrolling part + offset.top -= dom.getPosition(this.action_manager.el).top; } - this.action_manager.el.scrollTop = offset.top; this.action_manager.el.scrollLeft = offset.left; }, diff --git a/addons/web/static/src/js/tools/debug_manager.js b/addons/web/static/src/js/tools/debug_manager.js index dcdfc1a73bc..6f6022b021f 100644 --- a/addons/web/static/src/js/tools/debug_manager.js +++ b/addons/web/static/src/js/tools/debug_manager.js @@ -822,7 +822,7 @@ if (config.debug) { */ current_action_updated: function (action, controller) { this._super.apply(this, arguments); - var debugManager = _.find(this.systray_menu.widgets, function(item) { + var debugManager = _.find(this.menu.systray_menu.widgets, function(item) { return item instanceof DebugManager; }); debugManager.update('action', action, controller && controller.widget); diff --git a/addons/web/static/src/js/tools/test_menus.js b/addons/web/static/src/js/tools/test_menus.js index 6a8c9d2118b..acfcde87878 100644 --- a/addons/web/static/src/js/tools/test_menus.js +++ b/addons/web/static/src/js/tools/test_menus.js @@ -61,7 +61,7 @@ $listOfAppMenuItems = $(".o_app, .o_menuitem") } else { console.log("Odoo flavor: Community") - $listOfAppMenuItems = $('#oe_main_menu_navbar a.oe_menu_leaf, #oe_main_menu_navbar a.oe_menu_toggler'); + $listOfAppMenuItems = $('#oe_main_menu_navbar a.oe_menu_leaf, #oe_main_menu_navbar a.o_app'); } console.log('Found ', $listOfAppMenuItems.length, 'apps to test'); @@ -94,12 +94,7 @@ testedApps.push(element.dataset.menuXmlid); return testMenuItem(element).then(function () { var $subMenuItems; - var isEnterprise = odoo.session_info.server_version_info[5] === 'e'; - if (isEnterprise) { - $subMenuItems = $('.o_menu_entry_lvl_1, .o_menu_entry_lvl_2, .o_menu_entry_lvl_3, .o_menu_entry_lvl_4'); - } else { - $subMenuItems = $('div.o_sub_menu_content .oe_menu_leaf'); - } + $subMenuItems = $('.o_menu_entry_lvl_1, .o_menu_entry_lvl_2, .o_menu_entry_lvl_3, .o_menu_entry_lvl_4'); var testMenuDef = $.when(); testMenuDef = chainDeferred($subMenuItems, testMenuDef, testMenuItem); return testMenuDef; diff --git a/addons/web/static/src/scss/navbar.scss b/addons/web/static/src/scss/navbar.scss index 2d865dd70d9..160a1b87558 100644 --- a/addons/web/static/src/scss/navbar.scss +++ b/addons/web/static/src/scss/navbar.scss @@ -28,6 +28,10 @@ } } + .o_app { + cursor: pointer; + } + > .o_menu_brand { display: block; float: left; @@ -37,13 +41,7 @@ font-size: 22px; font-weight: 500; line-height: $o-navbar-height; - - @include media-breakpoint-down(sm) { - float: none; - margin: 0; - border-bottom: 1px solid $o-navbar-inverse-link-hover-bg; - color: transparent; - } + cursor: pointer; } > .o_menu_toggle { @@ -146,79 +144,4 @@ transform: translateY(-2px); } } - - @include media-breakpoint-down(sm) { - transition: height 200ms linear 0s; - - position: relative; - height: $o-navbar-height; - overflow: hidden; - - > ul { - > li { - float: none; - .dropdown-backdrop { - display: none; - } - - .dropdown-menu.show { - max-height: none; - } - } - - &.o_menu_sections { - width: 100%; - - .dropdown-menu.show { - position: static; - float: none; - background-color: transparent; - box-shadow: none; - border: none; - overflow: visible; - - > .dropdown-item { - background-color: transparent; - color: inherit; - } - } - } - - &.o_menu_systray { - @include o-position-absolute(0px, $o-navbar-height, auto, $o-navbar-height); - height: $o-navbar-height; - text-align: right; - - > li { - display: inline-block; - - .dropdown-menu.show { - @include o-position-absolute($o-navbar-height, 0, 0, 0); - position: fixed; - width: auto; - } - } - - .o_user_menu .oe_topbar_name { - display: none; - } - } - } - } -} - -@include media-breakpoint-down(sm) { - body.o_mobile_menu_opened > .o_main_navbar { - height: 100%; - overflow: auto; - } -} - -@include media-breakpoint-down(sm) { - .o_switch_company_menu > .dropdown-menu { - padding-top: 0px; - .bg-info { - padding: 10px; - } - } } diff --git a/addons/web/static/src/scss/navbar_mobile.scss b/addons/web/static/src/scss/navbar_mobile.scss new file mode 100644 index 00000000000..6ef917b8517 --- /dev/null +++ b/addons/web/static/src/scss/navbar_mobile.scss @@ -0,0 +1,95 @@ + +.o_main_navbar { + .o_app { + @include media-breakpoint-down(sm) { + float: none; + margin: 0; + border-bottom: 1px solid $o-navbar-inverse-link-hover-bg; + color: transparent !important; + } + } + + > .o_menu_brand { + @include media-breakpoint-down(sm) { + float: none; + margin: 0; + border-bottom: 1px solid $o-navbar-inverse-link-hover-bg; + color: transparent !important; + } + } + + @include media-breakpoint-down(sm) { + transition: height 200ms linear 0s; + + position: relative; + height: $o-navbar-height; + overflow: hidden; + + > ul { + > li { + float: none; + .dropdown-backdrop { + display: none; + } + + .dropdown-menu.show { + max-height: none; + } + } + + &.o_menu_sections { + width: 100%; + + .dropdown-menu.show { + position: static; + float: none; + background-color: transparent; + box-shadow: none; + border: none; + overflow: visible; + + > .dropdown-item { + background-color: transparent; + color: inherit; + } + } + } + + &.o_menu_systray { + @include o-position-absolute(0px, $o-navbar-height, auto, $o-navbar-height); + height: $o-navbar-height; + text-align: right; + + > li { + display: inline-block; + + .dropdown-menu.show { + @include o-position-absolute($o-navbar-height, 0, 0, 0); + position: fixed; + width: auto; + } + } + + .o_user_menu .oe_topbar_name { + display: none; + } + } + } + } +} + +@include media-breakpoint-down(sm) { + body.o_mobile_menu_opened > .o_main_navbar { + height: 100%; + overflow: auto; + } +} + +@include media-breakpoint-down(sm) { + .o_switch_company_menu > .dropdown-menu { + padding-top: 0px; + .bg-info { + padding: 10px; + } + } +} diff --git a/addons/web/static/src/scss/primary_variables.scss b/addons/web/static/src/scss/primary_variables.scss index 7601c2225a7..e3b520e47f1 100644 --- a/addons/web/static/src/scss/primary_variables.scss +++ b/addons/web/static/src/scss/primary_variables.scss @@ -74,7 +74,7 @@ $o-avatar-size: 90px; $o-statusbar-height: 33px; $o-label-font-size-factor: 0.8; -$o-navbar-height: 34px; +$o-navbar-height: 46px; $o-nb-calendar-colors: 24; diff --git a/addons/web/static/src/scss/webclient_extra.scss b/addons/web/static/src/scss/webclient_extra.scss index a79a9d34a2a..8e8e3630e70 100644 --- a/addons/web/static/src/scss/webclient_extra.scss +++ b/addons/web/static/src/scss/webclient_extra.scss @@ -24,17 +24,11 @@ select { //------------------------------------------------------------------------------ // Loading //------------------------------------------------------------------------------ + .o_loading { - position: fixed; - top: 0; - right: 50%; - padding: 4px 12px; - background-color: $o-enterprise-color; // FIXME probably supposed to be primary to be auto-adapted in enterprise ? + background-color: $o-brand-odoo; color: white; - text-align: center; - border-top: none; - border-bottom-right-radius: 8px; - border-bottom-left-radius: 8px; + padding: 4px; } //------------------------------------------------------------------------------ diff --git a/addons/web/static/src/scss/webclient_layout.scss b/addons/web/static/src/scss/webclient_layout.scss index 5fe5ab08c8b..c03e519021b 100644 --- a/addons/web/static/src/scss/webclient_layout.scss +++ b/addons/web/static/src/scss/webclient_layout.scss @@ -1,176 +1,90 @@ +// ------------------------------------------------------------------ +// Base layout rules, use the 'webclient.scss' file for styling +// ------------------------------------------------------------------ +html { + height: 100%; -.o_web_client { - display: flex; - flex-flow: column nowrap; - overflow: hidden; - - &.o_fullscreen { - #oe_main_menu_navbar { - display: none; - } - .o_main .o_sub_menu { - display: none; - } - } - - - .navbar { - flex: 0 0 auto; - - li > a { - padding: 4px 8px; - margin-top: 2px; - font-size: 13px; - } - - .oe_topbar_avatar { - max-height: 18px; - width: 18px; - margin-top: -4px; - } - - .oe_systray, .oe_user_menu_placeholder { - > li { - position: relative; - padding: 4px 0px; - - > a { - @include o-hover-text-color($navbar-dark-color, $navbar-dark-active-color); - } - } - } - } - - > .o_main { - flex: 1 1 auto; - display: flex; - min-height: 0; // Allow flex to shrink this element as much as it wants + .o_web_client { height: 100%; + display: flex; + flex-flow: column nowrap; - .o_sub_menu { - flex: 0 0 220px; - display: flex; - flex-flow: column nowrap; - border-right: 1px solid darken($o-control-panel-background-color, 20%); - background-color: $o-control-panel-background-color; - - .o_sub_menu_logo { - flex: 0 0 auto; - text-align: center; - position: relative; - - > img { - margin: 14px 0; - } - - .oe_logo_edit { - position: absolute; - top: 1px; - width: 100%; - padding: 4px; - margin: 14px 0; - display: none; - color: #eee; - background: rgba(37,37,37,0.9); - } - &:hover .oe_logo_edit_admin { - display: block; - } - } - - .o_sub_menu_content { - flex: 1 1 auto; - height:100%; - overflow-y: auto; - - .oe_secondary_menu_section { - font-weight: bold; - margin-left: 8px; - color: $o-brand-primary; - } - .oe_secondary_submenu { - margin: 3px 0px 10px; - line-height: 1.1em; - >li:not(.active) > a { - color: $o-main-text-color; - } - > li > a { - padding: 4px 4px 4px 18px; - > .badge { - margin-top: -1px; - } - } - .oe_secondary_submenu { - > li > a { - padding-left: 30px; - } - .oe_secondary_submenu > li > a { - padding-left: 42px; - } - } - .oe_menu_text { - @include o-text-overflow; - max-width: 85%; - margin-top: 1px; - } - .oe_menu_toggler:before { - display: inline-block; - width: 0; - height: 0; - content: "&darr"; - opacity: 0.5; - text-indent: -99999px; - vertical-align: top; - margin-left: -12px; - margin-top: 4px; - margin-right: 4px; - border-top: 4px solid transparent; - border-bottom: 4px solid transparent; - border-left: 4px solid #4c4c4c; - } - .oe_menu_opened:before { - margin-top: 6px; - margin-left: -16px; - margin-right: 4px; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-top: 4px solid #4c4c4c; - } - } - } - - .o_sub_menu_footer { - flex: 0 0 auto; - text-align: center; - - > a { - font-weight: bold; - > span { - color: $o-enterprise-color; // FIXME probably supposed to be primary to be auto-adapted in enterprise ? - } - } - } - } - - .o_main_content { + > .o_main { flex: 1 1 auto; display: flex; - flex-flow: column nowrap; + min-height: 0; // Allow flex to shrink this element as much as it wants + height: 100%; - .o_control_panel { - flex: 0 0 auto; - } - .o_content { + .o_main_content { flex: 1 1 auto; - height: 100%; - overflow: auto; - position: relative; + display: flex; + flex-flow: column nowrap; - > .o_view_controller { - @include o-position-absolute(0, 0, 0, 0); // Get the 100% height of its flex parent + .o_control_panel { + flex: 0 0 auto; + } + .o_content { + flex: 1 1 auto; height: 100%; + overflow: auto; + position: relative; + + > .o_view_controller { + @include o-position-absolute(0, 0, 0, 0); // Get the 100% height of its flex parent + height: 100%; + } } } } + + &.o_fullscreen { + .o_main_navbar { + display: none; + } + } + + .o_main_navbar { + flex: 0 0 auto; + } + + .o_control_panel { + flex: 0 0 auto; + } + + .o_content { + direction: ltr; //Define direction attribute here so when rtlcss preprocessor run, it converts it to rtl + flex: 1 1 auto; + position: relative; // Allow to redistribute the 100% height to its child + + > .o_view_controller { + position: absolute; // Get the 100% height of its flex parent + top: 0; + right: 0; + bottom: 0; + left: 0; + height: 100%; + direction: ltr; + } + } + } + + .o_loading { + position: fixed; + bottom: 0; + right: 0; + z-index: $zindex-modal + 1; + } +} + +@media print { + html .o_web_client { + .o_main_navbar { + display: none; + } + .o_content { + position: static; + overflow: visible; + height: auto; + } } } diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index c79dc52ad42..3e8a8c10e71 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -1531,7 +1531,7 @@
  • - diff --git a/addons/web/static/src/xml/menu.xml b/addons/web/static/src/xml/menu.xml new file mode 100644 index 00000000000..1649e630547 --- /dev/null +++ b/addons/web/static/src/xml/menu.xml @@ -0,0 +1,225 @@ + + + +
    + +
    + + + +
  • +
    + + + + + + + + + + +
  • + +
  • + + + + + +
    + +
    + + + + +
    + + + + +
    + + + + + + + + +
  • + + +
  • +
    +
    +
    +
    +
    + + + +

    + Odoo (Enterprise Edition) +

    +
    + + + +
    Database expiration:
    +
    +
    +
    + + + + Shortcuts + + + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    DescriptionWindows/LinuxMac
    Save a record + Alt + s + + Control + Alt + s +
    Edit a record + Alt + a + + Control + Alt + a +
    Discard a record modification + Alt + j + + Control + Alt + j +
    Create a new record + Alt + c + + Control + Alt + c +
    Open to list view + Alt + l + + Control + Alt + l +
    Open to kanban view + Alt + k + + Control + Alt + k +
    Open the previous record + Alt + p + + Control + Alt + p +
    Open the next record + Alt + n + + Control + Alt + n +
    Open app menu + Alt + h + + Control + Alt + h +
    +
    +
    +
    +
    + + + $(this).addClass("dropdown-menu-right"); + + + diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 8b210c29c52..a251541226c 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -235,6 +235,7 @@ + @@ -315,6 +316,7 @@ + @@ -414,65 +416,6 @@ - - - - -