[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
This commit is contained in:
Alexandre Kühn
2018-09-17 16:46:53 +02:00
parent 110d37a31d
commit 19eacf7d23
14 changed files with 926 additions and 625 deletions
+1
View File
@@ -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",
@@ -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;
});
+188 -146
View File
@@ -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;
});
+222 -143
View File
@@ -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;
},
@@ -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);
+2 -7
View File
@@ -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;
+5 -82
View File
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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;
@@ -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;
}
//------------------------------------------------------------------------------
+76 -162
View File
@@ -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;
}
}
}
+1 -1
View File
@@ -1531,7 +1531,7 @@
<t t-name="UserMenu">
<li class="o_user_menu">
<a class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false" href="#">
<a class="dropdown-toggle" data-toggle="dropdown" data-display="static" aria-expanded="false" href="#">
<img class="rounded-circle oe_topbar_avatar" t-att-src="_s + '/web/static/src/img/user_menu_avatar.png'" alt="Avatar"/>
<span class="oe_topbar_name"/>
</a>
+225
View File
@@ -0,0 +1,225 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<header t-name="Menu">
<nav class="o_main_navbar">
<ul class="o_menu_apps"/>
<a class="o_menu_brand" role="button"/>
<ul class="o_menu_sections" role="menu"/>
<ul class="o_menu_systray" role="menu"/>
</nav>
</header>
<!--
@param {web.AppsMenu} widget
-->
<t t-name="AppsMenu">
<li class="dropdown">
<a class="dropdown-toggle full" data-toggle="dropdown" href="#">
<i class="fa fa-th-large"/>
</a>
<t t-set="activeApp" t-value="widget.getActiveApp()"/>
<div class="dropdown-menu" role="menu">
<t t-foreach="widget.getApps()" t-as="app">
<a role="menuitem"
t-attf-class="dropdown-item o_app #{activeApp.menuID === app.menuID ? 'active' : ''}"
t-att-data-menu-id="app.menuID"
t-att-data-menu-xmlid="app.xmlID"
t-att-data-action-id="app.actionID">
<t t-esc="app.name"/>
</a>
</t>
</div>
</li>
</t>
<t t-name="Menu.link">
<t t-set="depth" t-value="(depth || 0) + 1"/>
<t t-if="!menu.children.length">
<t t-set="_link">
<a role="menuitem" t-attf-href="#menu_id=#{menu.id}&amp;action=#{menu.action ? menu.action.split(',')[1] : ''}"
t-att-data-menu="menu.id"
t-att-data-action-model="menu.action ? menu.action.split(',')[0] : ''"
t-att-data-action-id="menu.action ? menu.action.split(',')[1] : ''"
t-att-data-menu-xmlid="menu.xmlid"
t-attf-class="#{inNavbar ? '' : 'dropdown-item '}o_menu_entry_lvl_#{depth}"
data-toggle="collapse" data-target="#o_navbar_collapse.in">
<span><t t-esc="menu.name"/></span>
</a>
</t>
<li t-if="inNavbar">
<t t-raw="_link"/>
</li>
<t t-else="">
<t t-raw="_link"/>
</t>
</t>
<t t-else="">
<div t-attf-class="dropdown-header o_menu_header_lvl_#{depth}">
<span><t t-esc="menu.name"/></span>
</div>
<t t-foreach="menu.children" t-as="menu">
<t t-call="Menu.link"/>
</t>
</t>
</t>
<!-- separate first level dropdown from the others to avoid nested dropdown -->
<t t-name="Menu.sections">
<t t-foreach="menu_data.children" t-as="first_level_menu">
<section t-att-class="first_level_menu.id">
<t t-foreach="first_level_menu.children" t-as="second_level_menu">
<t t-if="!second_level_menu.children.length">
<t t-call="Menu.link">
<t t-set="inNavbar" t-value="true"/>
<t t-set="menu" t-value="second_level_menu"/>
</t>
</t>
<t t-else="">
<li>
<a href="#" class="dropdown-toggle o-no-caret o_menu_header_lvl_1" t-att-data-menu-xmlid="second_level_menu.xmlid" data-toggle="dropdown" role="button" aria-expanded="false">
<t t-esc="second_level_menu.name"/>
</a>
<div class="dropdown-menu" role="menu">
<t t-foreach="second_level_menu.children" t-as="third_level_menu">
<t t-call="Menu.link">
<t t-set="depth" t-value="1"/>
<t t-set="menu" t-value="third_level_menu"/>
</t>
</t>
</div>
</li>
</t>
</t>
</section>
</t>
</t>
<t t-extend="DashboardShare">
<t t-jquery=".user-heading h3" t-operation="replace">
<h3>
Odoo <t t-esc="server_version"/> (Enterprise Edition)
</h3>
</t>
<t t-jquery=".user-heading h3" t-operation="after">
<t t-set="expiration_date" t-value="new moment(widget.data.expiration_date).format('LL')"/>
<t t-if="expiration_date">
<h5>Database expiration: <t t-esc="expiration_date"/></h5>
</t>
</t>
</t>
<t t-extend="UserMenu.Actions">
<t t-jquery=".dropdown-divider" t-operation="before">
<a href="#" data-menu="shortcuts" class="dropdown-item d-none d-md-inline-block">Shortcuts</a>
</t>
</t>
<div t-name="UserMenu.shortcuts">
<div class="container-fluid">
<div class="row">
<div class="offset-md-2 col-md-8">
<table class="o_shortcut_table text-center">
<thead>
<tr>
<th style="width:50%;">Description</th>
<th style="width:25%;" class="text-center">Windows/Linux</th>
<th style="width:25%;" class="text-center">Mac</th>
</tr>
</thead>
<tbody>
<tr>
<td align="left">Save a record</td>
<td>
<span class="o_key">Alt</span> + <span class="o_key">s</span>
</td>
<td>
<span class="o_key">Control</span> + <span class="o_key">Alt</span> + <span class="o_key">s</span>
</td>
</tr>
<tr>
<td align="left">Edit a record</td>
<td>
<span class="o_key">Alt</span> + <span class="o_key">a</span>
</td>
<td>
<span class="o_key">Control</span> + <span class="o_key">Alt</span> + <span class="o_key">a</span>
</td>
</tr>
<tr>
<td align="left">Discard a record modification</td>
<td>
<span class="o_key">Alt</span> + <span class="o_key">j</span>
</td>
<td>
<span class="o_key">Control</span> + <span class="o_key">Alt</span> + <span class="o_key">j</span>
</td>
</tr>
<tr>
<td align="left">Create a new record</td>
<td>
<span class="o_key">Alt</span> + <span class="o_key">c</span>
</td>
<td>
<span class="o_key">Control</span> + <span class="o_key">Alt</span> + <span class="o_key">c</span>
</td>
</tr>
<tr>
<td align="left">Open to list view</td>
<td>
<span class="o_key">Alt</span> + <span class="o_key">l</span>
</td>
<td>
<span class="o_key">Control</span> + <span class="o_key">Alt</span> + <span class="o_key">l</span>
</td>
</tr>
<tr>
<td align="left">Open to kanban view</td>
<td>
<span class="o_key">Alt</span> + <span class="o_key">k</span>
</td>
<td>
<span class="o_key">Control</span> + <span class="o_key">Alt</span> + <span class="o_key">k</span>
</td>
</tr>
<tr>
<td align="left">Open the previous record</td>
<td>
<span class="o_key">Alt</span> + <span class="o_key">p</span>
</td>
<td>
<span class="o_key">Control</span> + <span class="o_key">Alt</span> + <span class="o_key">p</span>
</td>
</tr>
<tr>
<td align="left">Open the next record</td>
<td>
<span class="o_key">Alt</span> + <span class="o_key">n</span>
</td>
<td>
<span class="o_key">Control</span> + <span class="o_key">Alt</span> + <span class="o_key">n</span>
</td>
</tr>
<tr>
<td align="left">Open app menu</td>
<td>
<span class="o_key">Alt</span> + <span class="o_key">h</span>
</td>
<td>
<span class="o_key">Control</span> + <span class="o_key">Alt</span> + <span class="o_key">h</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<t t-extend="FormSelection">
<t t-jquery=".dropdown-menu.state">$(this).addClass("dropdown-menu-right");</t>
</t>
</templates>
+2 -73
View File
@@ -235,6 +235,7 @@
<script type="text/javascript" src="/web/static/src/js/widgets/switch_company_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/user_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/apps_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/search/search_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/search/search_view_mobile.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/search/search_filters.js"></script>
@@ -315,6 +316,7 @@
<t t-call="web._assets_bootstrap"/>
<link rel="stylesheet" type="text/scss" href="/web/static/lib/tempusdominus/tempusdominus.scss"/>
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/navbar_mobile.scss"/>
<script type="text/javascript" src="/web/static/src/js/services/session.js"></script>
</template>
@@ -414,65 +416,6 @@
</t>
</template>
<template id="web.menu">
<ul class="nav navbar-nav navbar-left d-none oe_application_menu_placeholder" role="menu">
<li t-foreach="menu_data['children']" t-as="menu" class="nav-item">
<t t-call="web.menu_link">
<t t-set="_classes" t-value="'nav-link'"/>
</t>
</li>
</ul>
<ul class="nav navbar-nav flex-nowrap ml-auto oe_systray" style="display: none;"/>
<ul class="nav navbar-nav flex-nowrap oe_user_menu_placeholder" style="display: none;"/>
</template>
<template id="web.menu_secondary">
<a class="o_sub_menu_logo" t-att-href="'/web/?debug' if debug else '/web'">
<span class="oe_logo_edit">Edit Company data</span>
<img src='/web/binary/company_logo' alt="Logo"/>
</a>
<div class="o_sub_menu_content">
<t t-foreach="menu_data['children']" t-as="menu">
<div style="display: none" class="oe_secondary_menu" t-att-data-menu-parent="menu['id']">
<t t-foreach="menu['children']" t-as="menu">
<div class="oe_secondary_menu_section" t-att-data-menu-xmlid="menu.get('xmlid')">
<t t-if="menu['children']"><t t-esc="menu['name']"/></t>
<t t-if="not menu['children']"><t t-call="web.menu_link"/></t>
</div>
<t t-call="web.menu_secondary_submenu"/>
</t>
</div>
</t>
</div>
<div class="o_sub_menu_footer">
Powered by <a href="http://www.odoo.com?utm_source=db&amp;utm_medium=auth" target="_blank"><span>Odoo</span></a>
</div>
</template>
<template id="web.menu_secondary_submenu">
<ul t-if="menu['children']" class="oe_secondary_submenu nav nav-pills flex-column">
<li t-foreach="menu['children']" t-as="menu" class="nav-item">
<t t-call="web.menu_link">
<t t-set="_classes" t-value="'nav-link'"/>
</t>
<t t-call="web.menu_secondary_submenu"/>
</li>
</ul>
</template>
<template id="web.menu_link">
<t t-set="debug_param" t-value="'?&amp;debug=' if debug else ''"/>
<a t-att-href="'/web%s#menu_id=%s&amp;action=%s' % (debug_param, menu['id'], menu['action'] and menu['action'].split(',')[1] or '')"
role="menuitem"
t-attf-class="#{_classes} #{'oe_menu_toggler' if menu.get('children') else 'oe_menu_leaf'}"
t-att-data-menu="menu['id']"
t-att-data-menu-xmlid="menu.get('xmlid')"
t-att-data-action-model="menu['action'] and menu['action'].split(',')[0] or None"
t-att-data-action-id="menu['action'] and menu['action'].split(',')[1] or None">
<span class="oe_menu_text">
<t t-esc="menu['name']"/>
</span>
</a>
</template>
<template id="web.js_tests_assets">
<link type="text/css" rel="stylesheet" href="/web/static/lib/qunit/qunit-2.3.0.css"/>
<script type="text/javascript" src="/web/static/lib/qunit/qunit-2.3.0.js"></script>
@@ -760,21 +703,7 @@
</t>
<t t-set="head" t-value="head_web + (head or '')"/>
<t t-set="body_classname" t-value="'o_web_client'"/>
<header id="oe_main_menu_navbar">
<nav class="navbar navbar-expand-md navbar-dark bg-dark" groups="base.group_user,base.group_portal">
<button type="button" class="navbar-toggler" data-toggle="collapse" data-target=".navbar-collapse">
<span class="navbar-toggler-icon"/>
</button>
<div class="navbar-collapse collapse">
<t t-call="web.menu"/>
</div>
</nav>
</header>
<div class="o_main">
<aside class="o_sub_menu" groups="base.group_user,base.group_portal" aria-label="Submenu">
<t t-call="web.menu_secondary"/>
</aside>
<main class="o_main_content"/>
</div>
</t>