summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/chrome
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/chrome
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/chrome')
-rw-r--r--addons/web/static/src/js/chrome/abstract_action.js192
-rw-r--r--addons/web/static/src/js/chrome/abstract_web_client.js556
-rw-r--r--addons/web/static/src/js/chrome/action_manager.js939
-rw-r--r--addons/web/static/src/js/chrome/action_manager_act_window.js732
-rw-r--r--addons/web/static/src/js/chrome/action_manager_report.js203
-rw-r--r--addons/web/static/src/js/chrome/action_mixin.js235
-rw-r--r--addons/web/static/src/js/chrome/apps_menu.js102
-rw-r--r--addons/web/static/src/js/chrome/keyboard_navigation_mixin.js261
-rw-r--r--addons/web/static/src/js/chrome/loading.js80
-rw-r--r--addons/web/static/src/js/chrome/menu.js243
-rw-r--r--addons/web/static/src/js/chrome/root_widget.js7
-rw-r--r--addons/web/static/src/js/chrome/systray_menu.js65
-rw-r--r--addons/web/static/src/js/chrome/user_menu.js132
-rw-r--r--addons/web/static/src/js/chrome/web_client.js238
14 files changed, 3985 insertions, 0 deletions
diff --git a/addons/web/static/src/js/chrome/abstract_action.js b/addons/web/static/src/js/chrome/abstract_action.js
new file mode 100644
index 00000000..fc5c1fd4
--- /dev/null
+++ b/addons/web/static/src/js/chrome/abstract_action.js
@@ -0,0 +1,192 @@
+odoo.define('web.AbstractAction', function (require) {
+"use strict";
+
+/**
+ * We define here the AbstractAction widget, which implements the ActionMixin.
+ * All client actions must extend this widget.
+ *
+ * @module web.AbstractAction
+ */
+
+var ActionMixin = require('web.ActionMixin');
+const ActionModel = require('web/static/src/js/views/action_model.js');
+var ControlPanel = require('web.ControlPanel');
+var Widget = require('web.Widget');
+const { ComponentWrapper } = require('web.OwlCompatibility');
+
+var AbstractAction = Widget.extend(ActionMixin, {
+ config: {
+ ControlPanel: ControlPanel,
+ },
+
+ /**
+ * If this flag is set to true, the client action will create a control
+ * panel whenever it is created.
+ *
+ * @type boolean
+ */
+ hasControlPanel: false,
+
+ /**
+ * If true, this flag indicates that the client action should automatically
+ * fetch the <arch> of a search view (or control panel view). Note that
+ * to do that, it also needs a specific modelName.
+ *
+ * For example, the Discuss application adds the following line in its
+ * constructor::
+ *
+ * this.searchModelConfig.modelName = 'mail.message';
+ *
+ * @type boolean
+ */
+ loadControlPanel: false,
+
+ /**
+ * A client action might want to use a search bar in its control panel, or
+ * it could choose not to use it.
+ *
+ * Note that it only makes sense if hasControlPanel is set to true.
+ *
+ * @type boolean
+ */
+ withSearchBar: false,
+
+ /**
+ * This parameter can be set to customize the available sub menus in the
+ * controlpanel (Filters/Group By/Favorites). This is basically a list of
+ * the sub menus that we want to use.
+ *
+ * Note that it only makes sense if hasControlPanel is set to true.
+ *
+ * For example, set ['filter', 'favorite'] to enable the Filters and
+ * Favorites menus.
+ *
+ * @type string[]
+ */
+ searchMenuTypes: [],
+
+ /**
+ * @override
+ *
+ * @param {Widget} parent
+ * @param {Object} action
+ * @param {Object} [options]
+ */
+ init: function (parent, action, options) {
+ this._super(parent);
+ this._title = action.display_name || action.name;
+
+ this.searchModelConfig = {
+ context: Object.assign({}, action.context),
+ domain: action.domain || [],
+ env: owl.Component.env,
+ searchMenuTypes: this.searchMenuTypes,
+ };
+ this.extensions = {};
+ if (this.hasControlPanel) {
+ this.extensions.ControlPanel = {
+ actionId: action.id,
+ withSearchBar: this.withSearchBar,
+ };
+
+ this.viewId = action.search_view_id && action.search_view_id[0];
+
+ this.controlPanelProps = {
+ action,
+ breadcrumbs: options && options.breadcrumbs,
+ withSearchBar: this.withSearchBar,
+ searchMenuTypes: this.searchMenuTypes,
+ };
+ }
+ },
+ /**
+ * The willStart method is actually quite complicated if the client action
+ * has a controlPanel, because it needs to prepare it.
+ *
+ * @override
+ */
+ willStart: async function () {
+ const superPromise = this._super(...arguments);
+ if (this.hasControlPanel) {
+ if (this.loadControlPanel) {
+ const { context, modelName } = this.searchModelConfig;
+ const options = { load_filters: this.searchMenuTypes.includes('favorite') };
+ const { arch, fields, favoriteFilters } = await this.loadFieldView(
+ modelName,
+ context || {},
+ this.viewId,
+ 'search',
+ options
+ );
+ const archs = { search: arch };
+ const { ControlPanel: controlPanelInfo } = ActionModel.extractArchInfo(archs);
+ Object.assign(this.extensions.ControlPanel, {
+ archNodes: controlPanelInfo.children,
+ favoriteFilters,
+ fields,
+ });
+ this.controlPanelProps.fields = fields;
+ }
+ }
+ this.searchModel = new ActionModel(this.extensions, this.searchModelConfig);
+ if (this.hasControlPanel) {
+ this.controlPanelProps.searchModel = this.searchModel;
+ }
+ return Promise.all([
+ superPromise,
+ this.searchModel.load(),
+ ]);
+ },
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+ if (this.hasControlPanel) {
+ if ('title' in this.controlPanelProps) {
+ this._setTitle(this.controlPanelProps.title);
+ }
+ this.controlPanelProps.title = this.getTitle();
+ this._controlPanelWrapper = new ComponentWrapper(this, this.config.ControlPanel, this.controlPanelProps);
+ await this._controlPanelWrapper.mount(this.el, { position: 'first-child' });
+
+ }
+ },
+ /**
+ * @override
+ */
+ destroy: function() {
+ this._super.apply(this, arguments);
+ ActionMixin.destroy.call(this);
+ },
+ /**
+ * @override
+ */
+ on_attach_callback: function () {
+ ActionMixin.on_attach_callback.call(this);
+ this.searchModel.on('search', this, this._onSearch);
+ if (this.hasControlPanel) {
+ this.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
+ }
+ },
+ /**
+ * @override
+ */
+ on_detach_callback: function () {
+ ActionMixin.on_detach_callback.call(this);
+ this.searchModel.off('search', this);
+ if (this.hasControlPanel) {
+ this.searchModel.off('get-controller-query-params', this);
+ }
+ },
+
+ /**
+ * @private
+ * @param {Object} [searchQuery]
+ */
+ _onSearch: function () {},
+});
+
+return AbstractAction;
+
+});
diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js
new file mode 100644
index 00000000..032b8706
--- /dev/null
+++ b/addons/web/static/src/js/chrome/abstract_web_client.js
@@ -0,0 +1,556 @@
+odoo.define('web.AbstractWebClient', function (require) {
+"use strict";
+
+/**
+ * AbstractWebClient
+ *
+ * This class defines a simple, basic web client. It is mostly functional.
+ * The WebClient is in some way the most important class for the web framework:
+ * - this is the class that instantiate everything else,
+ * - it is the top of the component tree,
+ * - it coordinates many events bubbling up
+ */
+
+var ActionManager = require('web.ActionManager');
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var config = require('web.config');
+var WarningDialog = require('web.CrashManager').WarningDialog;
+var data_manager = require('web.data_manager');
+var dom = require('web.dom');
+var KeyboardNavigationMixin = require('web.KeyboardNavigationMixin');
+var Loading = require('web.Loading');
+var RainbowMan = require('web.RainbowMan');
+var session = require('web.session');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+
+const env = require('web.env');
+
+var _t = core._t;
+
+var AbstractWebClient = Widget.extend(KeyboardNavigationMixin, {
+ dependencies: ['notification'],
+ events: _.extend({}, KeyboardNavigationMixin.events),
+ custom_events: {
+ call_service: '_onCallService',
+ clear_uncommitted_changes: function (e) {
+ this.clear_uncommitted_changes().then(e.data.callback);
+ },
+ toggle_fullscreen: function (event) {
+ this.toggle_fullscreen(event.data.fullscreen);
+ },
+ current_action_updated: function (ev) {
+ this.current_action_updated(ev.data.action, ev.data.controller);
+ },
+ // GENERIC SERVICES
+ // the next events are dedicated to generic services required by
+ // downstream widgets. Mainly side effects, such as rpcs, notifications
+ // or cache.
+ warning: '_onDisplayWarning',
+ load_action: '_onLoadAction',
+ load_views: function (event) {
+ var params = {
+ model: event.data.modelName,
+ context: event.data.context,
+ views_descr: event.data.views,
+ };
+ return data_manager
+ .load_views(params, event.data.options || {})
+ .then(event.data.on_success);
+ },
+ load_filters: function (event) {
+ return data_manager
+ .load_filters(event.data)
+ .then(event.data.on_success);
+ },
+ create_filter: '_onCreateFilter',
+ delete_filter: '_onDeleteFilter',
+ push_state: '_onPushState',
+ show_effect: '_onShowEffect',
+ // session
+ get_session: function (event) {
+ if (event.data.callback) {
+ event.data.callback(session);
+ }
+ },
+ do_action: function (event) {
+ const actionProm = this.do_action(event.data.action, event.data.options || {});
+ this.menu_dp.add(actionProm).then(function (result) {
+ if (event.data.on_success) {
+ event.data.on_success(result);
+ }
+ }).guardedCatch(function (result) {
+ if (event.data.on_fail) {
+ event.data.on_fail(result);
+ }
+ });
+ },
+ getScrollPosition: '_onGetScrollPosition',
+ scrollTo: '_onScrollTo',
+ set_title_part: '_onSetTitlePart',
+ webclient_started: '_onWebClientStarted',
+ },
+ init: function (parent) {
+ // a flag to determine that odoo is fully loaded
+ odoo.isReady = false;
+ this.client_options = {};
+ this._super(parent);
+ KeyboardNavigationMixin.init.call(this);
+ this.origin = undefined;
+ this._current_state = null;
+ this.menu_dp = new concurrency.DropPrevious();
+ this.action_mutex = new concurrency.Mutex();
+ this.set('title_part', {"zopenerp": "Odoo"});
+ this.env = env;
+ this.env.bus.on('set_title_part', this, this._onSetTitlePart);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ KeyboardNavigationMixin.start.call(this);
+ var self = this;
+
+ // we add the o_touch_device css class to allow CSS to target touch
+ // devices. This is only for styling purpose, if you need javascript
+ // specific behaviour for touch device, just use the config object
+ // exported by web.config
+ this.$el.toggleClass('o_touch_device', config.device.touch);
+ this.on("change:title_part", this, this._title_changed);
+ this._title_changed();
+
+ var state = $.bbq.getState();
+ // If not set on the url, retrieve cids from the local storage
+ // of from the default company on the user
+ var current_company_id = session.user_companies.current_company[0]
+ if (!state.cids) {
+ state.cids = utils.get_cookie('cids') !== null ? utils.get_cookie('cids') : String(current_company_id);
+ }
+ // If a key appears several times in the hash, it is available in the
+ // bbq state as an array containing all occurrences of that key
+ const cids = Array.isArray(state.cids) ? state.cids[0] : state.cids;
+ let stateCompanyIDS = cids.split(',').map(cid => parseInt(cid, 10));
+ var userCompanyIDS = _.map(session.user_companies.allowed_companies, function(company) {return company[0]});
+ // Check that the user has access to all the companies
+ if (!_.isEmpty(_.difference(stateCompanyIDS, userCompanyIDS))) {
+ state.cids = String(current_company_id);
+ stateCompanyIDS = [current_company_id]
+ }
+ // Update the user context with this configuration
+ session.user_context.allowed_company_ids = stateCompanyIDS;
+ $.bbq.pushState(state);
+ // Update favicon
+ $("link[type='image/x-icon']").attr('href', '/web/image/res.company/' + String(stateCompanyIDS[0]) + '/favicon/')
+
+ return session.is_bound
+ .then(function () {
+ self.$el.toggleClass('o_rtl', _t.database.parameters.direction === "rtl");
+ self.bind_events();
+ return Promise.all([
+ self.set_action_manager(),
+ self.set_loading()
+ ]);
+ }).then(function () {
+ if (session.session_is_valid()) {
+ return self.show_application();
+ } else {
+ // database manager needs the webclient to keep going even
+ // though it has no valid session
+ return Promise.resolve();
+ }
+ });
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ KeyboardNavigationMixin.destroy.call(this);
+ return this._super(...arguments);
+ },
+ bind_events: function () {
+ var self = this;
+ $('.oe_systray').show();
+ this.$el.on('mouseenter', '.oe_systray > div:not([data-toggle=tooltip])', function () {
+ $(this).attr('data-toggle', 'tooltip').tooltip().trigger('mouseenter');
+ });
+ // TODO: this handler seems useless since 11.0, should be removed
+ this.$el.on('click', '.oe_dropdown_toggle', function (ev) {
+ ev.preventDefault();
+ var $toggle = $(this);
+ var doc_width = $(document).width();
+ var $menu = $toggle.siblings('.oe_dropdown_menu');
+ $menu = $menu.length >= 1 ? $menu : $toggle.find('.oe_dropdown_menu');
+ var state = $menu.is('.oe_opened');
+ setTimeout(function () {
+ // Do not alter propagation
+ $toggle.add($menu).toggleClass('oe_opened', !state);
+ if (!state) {
+ // Move $menu if outside window's edge
+ var offset = $menu.offset();
+ var menu_width = $menu.width();
+ var x = doc_width - offset.left - menu_width - 2;
+ if (x < 0) {
+ $menu.offset({ left: offset.left + x }).width(menu_width);
+ }
+ }
+ }, 0);
+ });
+ core.bus.on('click', this, function (ev) {
+ $('.tooltip').remove();
+ if (!$(ev.target).is('input[type=file]')) {
+ $(this.el.getElementsByClassName('oe_dropdown_menu oe_opened')).removeClass('oe_opened');
+ $(this.el.getElementsByClassName('oe_dropdown_toggle oe_opened')).removeClass('oe_opened');
+ }
+ });
+ core.bus.on('connection_lost', this, this._onConnectionLost);
+ core.bus.on('connection_restored', this, this._onConnectionRestored);
+ },
+ set_action_manager: function () {
+ var self = this;
+ this.action_manager = new ActionManager(this, session.user_context);
+ this.env.bus.on('do-action', this, payload => {
+ this.do_action(payload.action, payload.options || {})
+ .then(payload.on_success || (() => {}))
+ .guardedCatch(payload.on_fail || (() => {}));
+ });
+ var fragment = document.createDocumentFragment();
+ return this.action_manager.appendTo(fragment).then(function () {
+ dom.append(self.$el, fragment, {
+ in_DOM: true,
+ callbacks: [{widget: self.action_manager}],
+ });
+ });
+ },
+ set_loading: function () {
+ this.loading = new Loading(this);
+ return this.loading.appendTo(this.$el);
+ },
+ show_application: function () {
+ },
+ clear_uncommitted_changes: function () {
+ return this.action_manager.clearUncommittedChanges();
+ },
+ destroy_content: function () {
+ _.each(_.clone(this.getChildren()), function (el) {
+ el.destroy();
+ });
+ this.$el.children().remove();
+ },
+ // --------------------------------------------------------------
+ // Window title handling
+ // --------------------------------------------------------------
+ /**
+ * Sets the first part of the title of the window, dedicated to the current action.
+ */
+ set_title: function (title) {
+ this.set_title_part("action", title);
+ },
+ /**
+ * Sets an arbitrary part of the title of the window. Title parts are
+ * identified by strings. Each time a title part is changed, all parts
+ * are gathered, ordered by alphabetical order and displayed in the title
+ * of the window separated by ``-``.
+ *
+ * @private
+ * @param {string} part
+ * @param {string} title
+ */
+ set_title_part: function (part, title) {
+ var tmp = _.clone(this.get("title_part"));
+ tmp[part] = title;
+ this.set("title_part", tmp);
+ },
+ _title_changed: function () {
+ var parts = _.sortBy(_.keys(this.get("title_part")), function (x) { return x; });
+ var tmp = "";
+ _.each(parts, function (part) {
+ var str = this.get("title_part")[part];
+ if (str) {
+ tmp = tmp ? tmp + " - " + str : str;
+ }
+ }, this);
+ document.title = tmp;
+ },
+ // --------------------------------------------------------------
+ // do_*
+ // --------------------------------------------------------------
+ /**
+ * When do_action is performed on the WebClient, forward it to the main ActionManager
+ * This allows to widgets that are not inside the ActionManager to perform do_action
+ */
+ do_action: function () {
+ return this.action_manager.doAction.apply(this.action_manager, arguments);
+ },
+ do_reload: function () {
+ var self = this;
+ return session.session_reload().then(function () {
+ session.load_modules(true).then(
+ self.menu.proxy('do_reload'));
+ });
+ },
+ do_push_state: function (state) {
+ if (!state.menu_id && this.menu) { // this.menu doesn't exist in the POS
+ state.menu_id = this.menu.getCurrentPrimaryMenu();
+ }
+ if ('title' in state) {
+ this.set_title(state.title);
+ delete state.title;
+ }
+ var url = '#' + $.param(state);
+ this._current_state = $.deparam($.param(state), false); // stringify all values
+ $.bbq.pushState(url);
+ this.trigger('state_pushed', state);
+ },
+ // --------------------------------------------------------------
+ // Connection notifications
+ // --------------------------------------------------------------
+ /**
+ * Handler to be overridden, called each time the UI is updated by the
+ * ActionManager.
+ *
+ * @param {Object} action the action of the currently displayed controller
+ * @param {Object} controller the currently displayed controller
+ */
+ current_action_updated: function (action, controller) {
+ },
+ //--------------------------------------------------------------
+ // Misc.
+ //--------------------------------------------------------------
+ toggle_fullscreen: function (fullscreen) {
+ this.$el.toggleClass('o_fullscreen', fullscreen);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns the left and top scroll positions of the main scrolling area
+ * (i.e. the '.o_content' div in desktop).
+ *
+ * @returns {Object} with keys left and top
+ */
+ getScrollPosition: function () {
+ var scrollingEl = this.action_manager.el.getElementsByClassName('o_content')[0];
+ return {
+ left: scrollingEl ? scrollingEl.scrollLeft : 0,
+ top: scrollingEl ? scrollingEl.scrollTop : 0,
+ };
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Calls the requested service from the env.
+ *
+ * For the ajax service, the arguments are extended with the target so that
+ * it can call back the caller.
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onCallService: function (ev) {
+ const payload = ev.data;
+ let args = payload.args || [];
+ if (payload.service === 'ajax' && payload.method === 'rpc') {
+ // ajax service uses an extra 'target' argument for rpc
+ args = args.concat(ev.target);
+ }
+ const service = this.env.services[payload.service];
+ const result = service[payload.method].apply(service, args);
+ payload.callback(result);
+ },
+ /**
+ * Whenever the connection is lost, we need to notify the user.
+ *
+ * @private
+ */
+ _onConnectionLost: function () {
+ this.connectionNotificationID = this.displayNotification({
+ message: _t('Connection lost. Trying to reconnect...'),
+ sticky: true
+ });
+ },
+ /**
+ * Whenever the connection is restored, we need to notify the user.
+ *
+ * @private
+ */
+ _onConnectionRestored: function () {
+ if (this.connectionNotificationID) {
+ this.call('notification', 'close', this.connectionNotificationID);
+ this.displayNotification({
+ type: 'info',
+ message: _t('Connection restored. You are back online.'),
+ sticky: false
+ });
+ this.connectionNotificationID = false;
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} e
+ * @param {Object} e.data.filter the filter description
+ * @param {function} e.data.on_success called when the RPC succeeds with its
+ * returned value as argument
+ */
+ _onCreateFilter: function (e) {
+ data_manager
+ .create_filter(e.data.filter)
+ .then(e.data.on_success);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} e
+ * @param {Object} e.data.filter the filter description
+ * @param {function} e.data.on_success called when the RPC succeeds with its
+ * returned value as argument
+ */
+ _onDeleteFilter: function (e) {
+ data_manager
+ .delete_filter(e.data.filterId)
+ .then(e.data.on_success);
+ },
+ /**
+ * Displays a warning in a dialog or with the notification service
+ *
+ * @private
+ * @param {OdooEvent} e
+ * @param {string} e.data.message the warning's message
+ * @param {string} e.data.title the warning's title
+ * @param {string} [e.data.type] 'dialog' to display in a dialog
+ * @param {boolean} [e.data.sticky] whether or not the warning should be
+ * sticky (if displayed with the Notification)
+ */
+ _onDisplayWarning: function (e) {
+ var data = e.data;
+ if (data.type === 'dialog') {
+ new WarningDialog(this, {
+ title: data.title,
+ }, data).open();
+ } else {
+ data.type = 'warning';
+ this.call('notification', 'notify', data);
+ }
+ },
+ /**
+ * Provides to the caller the current scroll position (left and top) of the
+ * main scrolling area of the webclient.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {function} ev.data.callback
+ */
+ _onGetScrollPosition: function (ev) {
+ ev.data.callback(this.getScrollPosition());
+ },
+ /**
+ * Loads an action from the database given its ID.
+ *
+ * @private
+ * @param {OdooEvent} event
+ * @param {integer} event.data.actionID
+ * @param {Object} event.data.context
+ * @param {function} event.data.on_success
+ */
+ _onLoadAction: function (event) {
+ data_manager
+ .load_action(event.data.actionID, event.data.context)
+ .then(event.data.on_success);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} e
+ */
+ _onPushState: function (e) {
+ this.do_push_state(_.extend(e.data.state, {'cids': $.bbq.getState().cids}));
+ },
+ /**
+ * Scrolls either to a given offset or to a target element (given a selector).
+ * It must be called with: trigger_up('scrollTo', options).
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {integer} [ev.data.top] the number of pixels to scroll from top
+ * @param {integer} [ev.data.left] the number of pixels to scroll from left
+ * @param {string} [ev.data.selector] the selector of the target element to
+ * scroll to
+ */
+ _onScrollTo: function (ev) {
+ var scrollingEl = this.action_manager.el.getElementsByClassName('o_content')[0];
+ if (!scrollingEl) {
+ return;
+ }
+ var offset = {top: ev.data.top, left: ev.data.left || 0};
+ if (ev.data.selector) {
+ offset = dom.getPosition(document.querySelector(ev.data.selector));
+ // Substract the position of the scrolling element
+ offset.top -= dom.getPosition(scrollingEl).top;
+ }
+
+ scrollingEl.scrollTop = offset.top;
+ scrollingEl.scrollLeft = offset.left;
+ },
+ /**
+ * @private
+ * @param {Object} payload
+ * @param {string} payload.part
+ * @param {string} [payload.title]
+ */
+ _onSetTitlePart: function (payload) {
+ var part = payload.part;
+ var title = payload.title;
+ this.set_title_part(part, title);
+ },
+ /**
+ * Displays a visual effect (for example, a rainbowman0
+ *
+ * @private
+ * @param {OdooEvent} e
+ * @param {Object} [e.data] - key-value options to decide rainbowman
+ * behavior / appearance
+ */
+ _onShowEffect: function (e) {
+ var data = e.data || {};
+ var type = data.type || 'rainbow_man';
+ if (type === 'rainbow_man') {
+ if (session.show_effect) {
+ new RainbowMan(data).appendTo(this.$el);
+ } else {
+ // For instance keep title blank, as we don't have title in data
+ this.call('notification', 'notify', {
+ title: "",
+ message: data.message,
+ sticky: false
+ });
+ }
+ } else {
+ throw new Error('Unknown effect type: ' + type);
+ }
+ },
+ /**
+ * Reacts to the end of the loading of the WebClient as a whole
+ * It allows for signalling to the rest of the ecosystem that the interface is usable
+ *
+ * @private
+ */
+ _onWebClientStarted: function() {
+ if (!this.isStarted) {
+ // Listen to 'scroll' event and propagate it on main bus
+ this.action_manager.$el.on('scroll', core.bus.trigger.bind(core.bus, 'scroll'));
+ odoo.isReady = true;
+ core.bus.trigger('web_client_ready');
+ if (session.uid === 1) {
+ this.$el.addClass('o_is_superuser');
+ }
+ this.isStarted = true;
+ }
+ }
+});
+
+return AbstractWebClient;
+
+});
diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js
new file mode 100644
index 00000000..6cb86030
--- /dev/null
+++ b/addons/web/static/src/js/chrome/action_manager.js
@@ -0,0 +1,939 @@
+odoo.define('web.ActionManager', function (require) {
+"use strict";
+
+/**
+ * ActionManager
+ *
+ * The ActionManager is one of the centrepieces in the WebClient architecture.
+ * Its role is to makes sure that Odoo actions are properly started and
+ * coordinated.
+ */
+
+var AbstractAction = require('web.AbstractAction');
+var concurrency = require('web.concurrency');
+var Context = require('web.Context');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var dom = require('web.dom');
+var framework = require('web.framework');
+var pyUtils = require('web.py_utils');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+var ActionManager = Widget.extend({
+ className: 'o_action_manager',
+ custom_events: {
+ breadcrumb_clicked: '_onBreadcrumbClicked',
+ history_back: '_onHistoryBack',
+ push_state: '_onPushState',
+ redirect: '_onRedirect',
+ },
+
+ /**
+ * @override
+ * @param {Object} [userContext={}]
+ */
+ init: function (parent, userContext) {
+ this._super.apply(this, arguments);
+ this.userContext = userContext || {};
+
+ // use a DropPrevious to drop previous actions when multiple actions are
+ // run simultaneously
+ this.dp = new concurrency.DropPrevious();
+
+ // 'actions' is an Object that registers the actions that are currently
+ // handled by the ActionManager (either stacked in the current window,
+ // or opened in dialogs)
+ this.actions = {};
+
+ // 'controllers' is an Object that registers the alive controllers
+ // linked registered actions, a controller being Object with keys
+ // (amongst others) 'jsID' (a local identifier) and 'widget' (the
+ // instance of the controller's widget)
+ this.controllers = {};
+
+ // 'controllerStack' is the stack of ids of the controllers currently
+ // displayed in the current window
+ this.controllerStack = [];
+
+ // 'currentDialogController' is the current controller opened in a
+ // dialog (i.e. coming from an action with target='new')
+ this.currentDialogController = null;
+ },
+ /**
+ * Called each time the action manager is attached into the DOM.
+ */
+ on_attach_callback: function () {
+ this.isInDOM = true;
+ var currentController = this.getCurrentController();
+ if (currentController) {
+ currentController.widget.on_attach_callback();
+ }
+ },
+ /**
+ * Called each time the action manager is detached from the DOM.
+ */
+ on_detach_callback: function () {
+ this.isInDOM = false;
+ var currentController = this.getCurrentController();
+ if (currentController) {
+ currentController.widget.on_detach_callback();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * This function is called when the current controller is about to be
+ * removed from the DOM, because a new one will be pushed, or an old one
+ * will be restored. It ensures that the current controller can be left (for
+ * instance, that it has no unsaved changes).
+ *
+ * @returns {Promise} resolved if the current controller can be left,
+ * rejected otherwise.
+ */
+ clearUncommittedChanges: function () {
+ var currentController = this.getCurrentController();
+ if (currentController) {
+ return currentController.widget.canBeRemoved();
+ }
+ return Promise.resolve();
+ },
+ /**
+ * This is the entry point to execute Odoo actions, given as an ID in
+ * database, an xml ID, a client action tag or an action descriptor.
+ *
+ * @param {number|string|Object} action the action to execute
+ * @param {Object} [options]
+ * @param {Object} [options.additional_context] additional context to be
+ * merged with the action's context.
+ * @param {boolean} [options.clear_breadcrumbs=false] set to true to clear
+ * the breadcrumbs history list
+ * @param {Function} [options.on_close] callback to be executed when the
+ * current action is active again (typically, if the new action is
+ * executed in target="new", on_close will be executed when the dialog is
+ * closed, if the current controller is still active)
+ * @param {Function} [options.on_reverse_breadcrumb] callback to be executed
+ * whenever an anterior breadcrumb item is clicked on
+ * @param {boolean} [options.pushState=true] set to false to prevent the
+ * ActionManager from pushing the state when the action is executed (this
+ * is useful when we come from a loadState())
+ * @param {boolean} [options.replace_last_action=false] set to true to
+ * replace last part of the breadcrumbs with the action
+ * @return {Promise<Object>} resolved with the action when the action is
+ * loaded and appended to the DOM ; rejected if the action can't be
+ * executed (e.g. if doAction has been called to execute another action
+ * before this one was complete).
+ */
+ doAction: function (action, options) {
+ var self = this;
+ options = _.defaults({}, options, {
+ additional_context: {},
+ clear_breadcrumbs: false,
+ on_close: function () {},
+ on_reverse_breadcrumb: function () {},
+ pushState: true,
+ replace_last_action: false,
+ });
+
+ // build or load an action descriptor for the given action
+ var def;
+ if (_.isString(action) && core.action_registry.contains(action)) {
+ // action is a tag of a client action
+ action = { type: 'ir.actions.client', tag: action };
+ } else if (_.isNumber(action) || _.isString(action)) {
+ // action is an id or xml id
+ def = this._loadAction(action, {
+ active_id: options.additional_context.active_id,
+ active_ids: options.additional_context.active_ids,
+ active_model: options.additional_context.active_model,
+ }).then(function (result) {
+ action = result;
+ });
+ }
+
+ return this.dp.add(Promise.resolve(def)).then(function () {
+ // action.target 'main' is equivalent to 'current' except that it
+ // also clears the breadcrumbs
+ options.clear_breadcrumbs = action.target === 'main' ||
+ options.clear_breadcrumbs;
+
+ self._preprocessAction(action, options);
+
+ return self._handleAction(action, options).then(function () {
+ // now that the action has been executed, force its 'pushState'
+ // flag to 'true', as we don't want to prevent its controller
+ // from pushing its state if it changes in the future
+ action.pushState = true;
+
+ return action;
+ });
+ }).then(function(action) {
+ self.trigger_up('webclient_started');
+ return action;
+ });
+ },
+ /**
+ * Compatibility with client actions that are still using do_push_state.
+ *
+ * @todo: convert all of them to trigger_up('push_state') instead.
+ * @param {Object} state
+ */
+ do_push_state: function (state) {
+ this.trigger_up('push_state', {state: state});
+ },
+ /**
+ * Returns the action of the last controller in the controllerStack, i.e.
+ * the action of the currently displayed controller in the main window (not
+ * in a dialog), and null if there is no controller in the stack.
+ *
+ * @returns {Object|null}
+ */
+ getCurrentAction: function () {
+ var controller = this.getCurrentController();
+ return controller ? this.actions[controller.actionID] : null;
+ },
+ /**
+ * Returns the last controller in the controllerStack, i.e. the currently
+ * displayed controller in the main window (not in a dialog), and
+ * null if there is no controller in the stack.
+ *
+ * @returns {Object|null}
+ */
+ getCurrentController: function () {
+ var currentControllerID = _.last(this.controllerStack);
+ return currentControllerID ? this.controllers[currentControllerID] : null;
+ },
+ /**
+ * Updates the UI according to the given state, for instance, executes a new
+ * action, or updates the state of the current action.
+ *
+ * @param {Object} state
+ * @param {integer|string} [state.action] the action to execute (given its
+ * id or tag for client actions)
+ * @returns {Promise} resolved when the UI has been updated
+ */
+ loadState: function (state) {
+ var action;
+ if (!state.action) {
+ return Promise.resolve();
+ }
+ if (_.isString(state.action) && core.action_registry.contains(state.action)) {
+ action = {
+ params: state,
+ tag: state.action,
+ type: 'ir.actions.client',
+ };
+ } else {
+ action = state.action;
+ }
+ return this.doAction(action, {
+ clear_breadcrumbs: true,
+ pushState: false,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Appends the given controller to the DOM and restores its scroll position.
+ * Also updates the control panel.
+ *
+ * @private
+ * @param {Object} controller
+ */
+ _appendController: function (controller) {
+ dom.append(this.$el, controller.widget.$el, {
+ in_DOM: this.isInDOM,
+ callbacks: [{widget: controller.widget}],
+ });
+
+ if (controller.scrollPosition) {
+ this.trigger_up('scrollTo', controller.scrollPosition);
+ }
+ },
+ /**
+ * Closes the current dialog, if any. Because we listen to the 'closed'
+ * event triggered by the dialog when it is closed, this also destroys the
+ * embedded controller and removes the reference to the corresponding action.
+ * This also executes the 'on_close' handler in some cases, and may also
+ * provide infos for closing this dialog.
+ *
+ * @private
+ * @param {Object} options
+ * @param {Object} [options.infos] some infos related to the closing the
+ * dialog.
+ * @param {boolean} [options.silent=false] if true, the 'on_close' handler
+ * won't be called ; this is in general the case when the current dialog
+ * is closed because another action is opened, so we don't want the former
+ * action to execute its handler as it won't be displayed anyway
+ */
+ _closeDialog: function (options) {
+ if (this.currentDialogController) {
+ this.currentDialogController.dialog.destroy(options);
+ }
+ },
+ /**
+ * Detaches the current controller from the DOM and stores its scroll
+ * position, in case we'd come back to that controller later.
+ *
+ * @private
+ */
+ _detachCurrentController: function () {
+ var currentController = this.getCurrentController();
+ if (currentController) {
+ currentController.scrollPosition = this._getScrollPosition();
+ dom.detach([{widget: currentController.widget}]);
+ }
+ },
+ /**
+ * Executes actions for which a controller has to be appended to the DOM,
+ * either in the main content (target="current", by default), or in a dialog
+ * (target="new").
+ *
+ * @private
+ * @param {Object} action
+ * @param {widget} action.controller a Widget instance to append to the DOM
+ * @param {string} [action.target="current"] set to "new" to render the
+ * controller in a dialog
+ * @param {Object} options @see doAction for details
+ * @returns {Promise} resolved when the controller is started and appended
+ */
+ _executeAction: function (action, options) {
+ var self = this;
+ this.actions[action.jsID] = action;
+
+ if (action.target === 'new') {
+ return this._executeActionInDialog(action, options);
+ }
+
+ var controller = self.controllers[action.controllerID];
+ return this.clearUncommittedChanges()
+ .then(function () {
+ return self.dp.add(self._startController(controller));
+ })
+ .then(function () {
+ if (self.currentDialogController) {
+ self._closeDialog({ silent: true });
+ }
+
+ // store the optional 'on_reverse_breadcrumb' handler
+ // AAB: store it on the AbstractAction instance, and call it
+ // automatically when the action is restored
+ if (options.on_reverse_breadcrumb) {
+ var currentAction = self.getCurrentAction();
+ if (currentAction) {
+ currentAction.on_reverse_breadcrumb = options.on_reverse_breadcrumb;
+ }
+ }
+
+ // update the internal state and the DOM
+ self._pushController(controller);
+
+ // store the action into the sessionStorage so that it can be
+ // fully restored on F5
+ self.call('session_storage', 'setItem', 'current_action', action._originalAction);
+
+ return action;
+ })
+ .guardedCatch(function () {
+ self._removeAction(action.jsID);
+ });
+ },
+ /**
+ * Executes actions with attribute target='new'. Such actions are rendered
+ * in a dialog.
+ *
+ * @private
+ * @param {Object} action
+ * @param {Object} options @see doAction for details
+ * @returns {Promise} resolved when the controller is rendered inside a
+ * dialog appended to the DOM
+ */
+ _executeActionInDialog: function (action, options) {
+ var self = this;
+ var controller = this.controllers[action.controllerID];
+ var widget = controller.widget;
+
+ return this._startController(controller).then(function (controller) {
+ var prevDialogOnClose;
+ if (self.currentDialogController) {
+ prevDialogOnClose = self.currentDialogController.onClose;
+ self._closeDialog({ silent: true });
+ }
+
+ controller.onClose = prevDialogOnClose || options.on_close;
+ var dialog = new Dialog(self, _.defaults({}, options, {
+ buttons: [],
+ dialogClass: controller.className,
+ title: action.name,
+ size: action.context.dialog_size,
+ }));
+ /**
+ * @param {Object} [options={}]
+ * @param {Object} [options.infos] if provided and `silent` is
+ * unset, the `on_close` handler will pass this information,
+ * which gives some context for closing this dialog.
+ * @param {boolean} [options.silent=false] if set, do not call the
+ * `on_close` handler.
+ */
+ dialog.on('closed', self, function (options) {
+ options = options || {};
+ self._removeAction(action.jsID);
+ self.currentDialogController = null;
+ if (options.silent !== true) {
+ controller.onClose(options.infos);
+ }
+ });
+ controller.dialog = dialog;
+
+ return dialog.open().opened(function () {
+ self.currentDialogController = controller;
+ widget.setParent(dialog);
+ dom.append(dialog.$el, widget.$el, {
+ in_DOM: true,
+ callbacks: [{widget: controller.widget}],
+ });
+ widget.renderButtons(dialog.$footer);
+ dialog.rebindButtonBehavior();
+
+ return action;
+ });
+ }).guardedCatch(function () {
+ self._removeAction(action.jsID);
+ });
+ },
+ /**
+ * Executes actions of type 'ir.actions.client'.
+ *
+ * @private
+ * @param {Object} action the description of the action to execute
+ * @param {string} action.tag the key of the action in the action_registry
+ * @param {Object} options @see doAction for details
+ * @returns {Promise} resolved when the client action has been executed
+ */
+ _executeClientAction: function (action, options) {
+ var self = this;
+ var ClientAction = core.action_registry.get(action.tag);
+ if (!ClientAction) {
+ console.error("Could not find client action " + action.tag, action);
+ return Promise.reject();
+ }
+ if (!(ClientAction.prototype instanceof Widget)) {
+ // the client action might be a function, which is executed and
+ // whose returned value might be another action to execute
+ var next = ClientAction(this, action);
+ if (next) {
+ return this.doAction(next, options);
+ }
+ return Promise.resolve();
+ }
+ if (!(ClientAction.prototype instanceof AbstractAction)) {
+ console.warn('The client action ' + action.tag + ' should be an instance of AbstractAction!');
+ }
+
+ var controllerID = _.uniqueId('controller_');
+
+ var index = this._getControllerStackIndex(options);
+ options.breadcrumbs = this._getBreadcrumbs(this.controllerStack.slice(0, index));
+ options.controllerID = controllerID;
+ var widget = new ClientAction(this, action, options);
+ var controller = {
+ actionID: action.jsID,
+ index: index,
+ jsID: controllerID,
+ title: widget.getTitle(),
+ widget: widget,
+ };
+ this.controllers[controllerID] = controller;
+ action.controllerID = controllerID;
+ var prom = this._executeAction(action, options);
+ prom.then(function () {
+ self._pushState(controllerID, {});
+ });
+ return prom;
+ },
+ /**
+ * Executes actions of type 'ir.actions.act_window_close', i.e. closes the
+ * last opened dialog.
+ *
+ * The action may also specify an effect to display right after the close
+ * action (e.g. rainbow man), or provide a reason for the close action.
+ * This is useful for decision making for the `on_close` handler.
+ *
+ * @private
+ * @param {Object} action
+ * @param {Object} [action.effect] effect to show up, e.g. rainbow man.
+ * @param {Object} [action.infos] infos on performing the close action.
+ * Useful for providing some context for the `on_close` handler.
+ * @returns {Promise} resolved immediately
+ */
+ _executeCloseAction: function (action, options) {
+ var result;
+ if (!this.currentDialogController) {
+ result = options.on_close(action.infos);
+ }
+
+ this._closeDialog({ infos: action.infos });
+
+ // display some effect (like rainbowman) on appropriate actions
+ if (action.effect) {
+ this.trigger_up('show_effect', action.effect);
+ }
+
+ return Promise.resolve(result);
+ },
+ /**
+ * Executes actions of type 'ir.actions.server'.
+ *
+ * @private
+ * @param {Object} action the description of the action to execute
+ * @param {integer} action.id the db ID of the action to execute
+ * @param {Object} [action.context]
+ * @param {Object} options @see doAction for details
+ * @returns {Promise} resolved when the action has been executed
+ */
+ _executeServerAction: function (action, options) {
+ var self = this;
+ var runDef = this._rpc({
+ route: '/web/action/run',
+ params: {
+ action_id: action.id,
+ context: action.context || {},
+ },
+ });
+ return this.dp.add(runDef).then(function (action) {
+ action = action || { type: 'ir.actions.act_window_close' };
+ return self.doAction(action, options);
+ });
+ },
+ /**
+ * Executes actions of type 'ir.actions.act_url', i.e. redirects to the
+ * given url.
+ *
+ * @private
+ * @param {Object} action the description of the action to execute
+ * @param {string} action.url
+ * @param {string} [action.target] set to 'self' to redirect in the current page,
+ * redirects to a new page by default
+ * @param {Object} options @see doAction for details
+ * @returns {Promise} resolved when the redirection is done (immediately
+ * when redirecting to a new page)
+ */
+ _executeURLAction: function (action, options) {
+ var url = action.url;
+
+ if (action.target === 'self') {
+ framework.redirect(url);
+ return Promise.resolve();
+ } else {
+ var w = window.open(url, '_blank');
+ if (!w || w.closed || typeof w.closed === 'undefined') {
+ var message = _t('A popup window has been blocked. You ' +
+ 'may need to change your browser settings to allow ' +
+ 'popup windows for this page.');
+ this.do_warn(false, message, true);
+ }
+ }
+
+ options.on_close();
+
+ return Promise.resolve();
+ },
+ /**
+ * Returns a description of the controllers in the given controller stack.
+ * It is used to render the breadcrumbs. It is an array of Objects with keys
+ * 'title' (what to display in the breadcrumbs) and 'controllerID' (the ID
+ * of the corresponding controller, used to restore it when this part of the
+ * breadcrumbs is clicked).
+ *
+ * @private
+ * @param {string[]} controllerStack
+ * @returns {Object[]}
+ */
+ _getBreadcrumbs: function (controllerStack) {
+ var self = this;
+ return _.map(controllerStack, function (controllerID) {
+ return {
+ controllerID: controllerID,
+ title: self.controllers[controllerID].title,
+ };
+ });
+ },
+ /**
+ * Returns the index where a controller should be inserted in the controller
+ * stack according to the given options. By default, a controller is pushed
+ * on the top of the stack.
+ *
+ * @private
+ * @param {options} [options.clear_breadcrumbs=false] if true, insert at
+ * index 0 and remove all other controllers
+ * @param {options} [options.index=null] if given, that index is returned
+ * @param {options} [options.replace_last_action=false] if true, replace the
+ * last controller of the stack
+ * @returns {integer} index
+ */
+ _getControllerStackIndex: function (options) {
+ var index;
+ if ('index' in options) {
+ index = options.index;
+ } else if (options.clear_breadcrumbs) {
+ index = 0;
+ } else if (options.replace_last_action) {
+ index = this.controllerStack.length - 1;
+ } else {
+ index = this.controllerStack.length;
+ }
+ return index;
+ },
+ /**
+ * Returns an object containing information about the given controller, like
+ * its title, its action's id, the active_id and active_ids of the action...
+ *
+ * @private
+ * @param {string} controllerID
+ * @returns {Object}
+ */
+ _getControllerState: function (controllerID) {
+ var controller = this.controllers[controllerID];
+ var action = this.actions[controller.actionID];
+ var state = {
+ title: controller.widget.getTitle(),
+ };
+ if (action.id) {
+ state.action = action.id;
+ } else if (action.type === 'ir.actions.client') {
+ state.action = action.tag;
+ var params = _.pick(action.params, function (v) {
+ return _.isString(v) || _.isNumber(v);
+ });
+ state = _.extend(params || {}, state);
+ }
+ if (action.context) {
+ var active_id = action.context.active_id;
+ if (active_id) {
+ state.active_id = active_id;
+ }
+ var active_ids = action.context.active_ids;
+ // we don't push active_ids if it's a single element array containing the active_id
+ // to make the url shorter in most cases
+ if (active_ids && !(active_ids.length === 1 && active_ids[0] === active_id)) {
+ state.active_ids = action.context.active_ids.join(',');
+ }
+ }
+ state = _.extend({}, controller.widget.getState(), state);
+ return state;
+ },
+ /**
+ * Returns the current horizontal and vertical scroll positions.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getScrollPosition: function () {
+ var scrollPosition;
+ this.trigger_up('getScrollPosition', {
+ callback: function (_scrollPosition) {
+ scrollPosition = _scrollPosition;
+ }
+ });
+ return scrollPosition;
+ },
+ /**
+ * Dispatches the given action to the corresponding handler to execute it,
+ * according to its type. This function can be overridden to extend the
+ * range of supported action types.
+ *
+ * @private
+ * @param {Object} action
+ * @param {string} action.type
+ * @param {Object} options
+ * @returns {Promise} resolved when the action has been executed ; rejected
+ * if the type of action isn't supported, or if the action can't be
+ * executed
+ */
+ _handleAction: function (action, options) {
+ if (!action.type) {
+ console.error("No type for action", action);
+ return Promise.reject();
+ }
+ switch (action.type) {
+ case 'ir.actions.act_url':
+ return this._executeURLAction(action, options);
+ case 'ir.actions.act_window_close':
+ return this._executeCloseAction(action, options);
+ case 'ir.actions.client':
+ return this._executeClientAction(action, options);
+ case 'ir.actions.server':
+ return this._executeServerAction(action, options);
+ default:
+ console.error("The ActionManager can't handle actions of type " +
+ action.type, action);
+ return Promise.reject();
+ }
+ },
+ /**
+ * Updates the internal state and the DOM with the given controller as
+ * current controller.
+ *
+ * @private
+ * @param {Object} controller
+ * @param {string} controller.jsID
+ * @param {Widget} controller.widget
+ * @param {integer} controller.index the controller is pushed at that
+ * position in the controller stack and controllers with an higher index
+ * are destroyed
+ */
+ _pushController: function (controller) {
+ var self = this;
+
+ // detach the current controller
+ this._detachCurrentController();
+
+ // push the new controller to the stack at the given position, and
+ // destroy controllers with an higher index
+ var toDestroy = this.controllerStack.slice(controller.index);
+ // reject from the list of controllers to destroy the one that we are
+ // currently pushing, or those linked to the same action as the one
+ // linked to the controller that we are pushing
+ toDestroy = _.reject(toDestroy, function (controllerID) {
+ return controllerID === controller.jsID ||
+ self.controllers[controllerID].actionID === controller.actionID;
+ });
+ this._removeControllers(toDestroy);
+ this.controllerStack = this.controllerStack.slice(0, controller.index);
+ this.controllerStack.push(controller.jsID);
+
+ // append the new controller to the DOM
+ this._appendController(controller);
+
+ // notify the environment of the new action
+ this.trigger_up('current_action_updated', {
+ action: this.getCurrentAction(),
+ controller: controller,
+ });
+
+ // close all dialogs when the current controller changes
+ core.bus.trigger('close_dialogs');
+
+ // toggle the fullscreen mode for actions in target='fullscreen'
+ this._toggleFullscreen();
+ },
+ /**
+ * Pushes the given state, with additional information about the given
+ * controller, like the action's id and the controller's title.
+ *
+ * @private
+ * @param {string} controllerID
+ * @param {Object} [state={}]
+ */
+ _pushState: function (controllerID, state) {
+ var controller = this.controllers[controllerID];
+ if (controller) {
+ var action = this.actions[controller.actionID];
+ if (action.target === 'new' || action.pushState === false) {
+ // do not push state for actions in target="new" or for actions
+ // that have been explicitly marked as not pushable
+ return;
+ }
+ state = _.extend({}, state, this._getControllerState(controller.jsID));
+ this.trigger_up('push_state', {state: state});
+ }
+ },
+ /**
+ * Loads an action from the database given its ID.
+ *
+ * @todo: turn this in a service (DataManager)
+ * @private
+ * @param {integer|string} action's ID or xml ID
+ * @param {Object} context
+ * @returns {Promise<Object>} resolved with the description of the action
+ */
+ _loadAction: function (actionID, context) {
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ self.trigger_up('load_action', {
+ actionID: actionID,
+ context: context,
+ on_success: resolve,
+ });
+ });
+ },
+ /**
+ * Preprocesses the action before it is handled by the ActionManager
+ * (assigns a JS id, evaluates its context and domains, etc.).
+ *
+ * @param {Object} action
+ * @param {Object} options see @doAction options
+ */
+ _preprocessAction: function (action, options) {
+ // ensure that the context and domain are evaluated
+ var context = new Context(this.userContext, options.additional_context, action.context);
+ action.context = pyUtils.eval('context', context);
+ if (action.domain) {
+ action.domain = pyUtils.eval('domain', action.domain, action.context);
+ }
+
+ action._originalAction = JSON.stringify(action);
+
+ action.jsID = _.uniqueId('action_');
+ action.pushState = options.pushState;
+ },
+ /**
+ * Unlinks the given action and its controller from the internal structures
+ * and destroys its controllers.
+ *
+ * @private
+ * @param {string} actionID the id of the action to remove
+ */
+ _removeAction: function (actionID) {
+ var action = this.actions[actionID];
+ var controller = this.controllers[action.controllerID];
+ delete this.actions[action.jsID];
+ delete this.controllers[action.controllerID];
+ controller.widget.destroy();
+ },
+ /**
+ * Removes the given controllers and their corresponding actions.
+ *
+ * @see _removeAction
+ * @private
+ * @param {string[]} controllerIDs
+ */
+ _removeControllers: function (controllerIDs) {
+ var self = this;
+ var actionsToRemove = _.map(controllerIDs, function (controllerID) {
+ return self.controllers[controllerID].actionID;
+ });
+ _.each(_.uniq(actionsToRemove), this._removeAction.bind(this));
+ },
+ /**
+ * Restores a controller from the controllerStack and destroys all
+ * controllers stacked over the given controller (called when coming back
+ * using the breadcrumbs).
+ *
+ * @private
+ * @param {string} controllerID
+ * @returns {Promise} resolved when the controller has been restored
+ */
+ _restoreController: function (controllerID) {
+ var self = this;
+ var controller = this.controllers[controllerID];
+ // AAB: AbstractAction should define a proper hook to execute code when
+ // it is restored (other than do_show), and it should return a promise
+ var action = this.actions[controller.actionID];
+ var def;
+ if (action.on_reverse_breadcrumb) {
+ def = action.on_reverse_breadcrumb();
+ }
+ return Promise.resolve(def).then(function () {
+ return Promise.resolve(controller.widget.do_show()).then(function () {
+ var index = _.indexOf(self.controllerStack, controllerID);
+ self._pushController(controller, index);
+ });
+ });
+ },
+ /**
+ * Starts the controller by appending it in a document fragment, so that it
+ * is ready when it will be appended to the DOM. This allows to prevent
+ * flickering for widgets doing async stuff in willStart() or start().
+ *
+ * Also updates the control panel on any change of the title on controller's
+ * widget.
+ *
+ * @private
+ * @param {Object} controller
+ * @returns {Promise<Object>} resolved with the controller when it is ready
+ */
+ _startController: function (controller) {
+ var fragment = document.createDocumentFragment();
+ return controller.widget.appendTo(fragment).then(function () {
+ return controller;
+ });
+ },
+ /**
+ * Toggles the fullscreen mode if there is an action in target='fullscreen'
+ * in the current stack.
+ *
+ * @private
+ */
+ _toggleFullscreen: function () {
+ var self = this;
+ var fullscreen = _.some(this.controllerStack, function (controllerID) {
+ var controller = self.controllers[controllerID];
+ return self.actions[controller.actionID].target === 'fullscreen';
+ });
+ this.trigger_up('toggle_fullscreen', {fullscreen: fullscreen});
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {string} ev.data.controllerID
+ */
+ _onBreadcrumbClicked: function (ev) {
+ ev.stopPropagation();
+ this._restoreController(ev.data.controllerID);
+ },
+ /**
+ * Goes back in the history: if a controller is opened in a dialog, closes
+ * the dialog, otherwise, restores the second to last controller from the
+ * stack.
+ *
+ * @private
+ */
+ _onHistoryBack: function () {
+ if (this.currentDialogController) {
+ this._closeDialog();
+ } else {
+ var length = this.controllerStack.length;
+ if (length > 1) {
+ this._restoreController(this.controllerStack[length - 2]);
+ }
+ }
+ },
+ /**
+ * Intercepts and triggers a new push_state event, with additional
+ * information about the given controller.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {string} ev.controllerID
+ * @param {Object} [ev.state={}]
+ */
+ _onPushState: function (ev) {
+ if (ev.target !== this) {
+ ev.stopPropagation();
+ this._pushState(ev.data.controllerID, ev.data.state);
+ }
+ },
+ /**
+ * Intercepts and triggers a redirection on a link.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {integer} ev.data.res_id
+ * @param {string} ev.data.res_model
+ */
+ _onRedirect: function (ev) {
+ this.do_action({
+ type:'ir.actions.act_window',
+ view_mode: 'form',
+ res_model: ev.data.res_model,
+ views: [[false, 'form']],
+ res_id: ev.data.res_id,
+ });
+ },
+});
+
+return ActionManager;
+
+});
diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js
new file mode 100644
index 00000000..68a52ea1
--- /dev/null
+++ b/addons/web/static/src/js/chrome/action_manager_act_window.js
@@ -0,0 +1,732 @@
+odoo.define('web.ActWindowActionManager', function (require) {
+"use strict";
+
+/**
+ * The purpose of this file is to add the support of Odoo actions of type
+ * 'ir.actions.act_window' to the ActionManager.
+ */
+
+var ActionManager = require('web.ActionManager');
+var config = require('web.config');
+var Context = require('web.Context');
+var core = require('web.core');
+var pyUtils = require('web.py_utils');
+var view_registry = require('web.view_registry');
+
+ActionManager.include({
+ custom_events: _.extend({}, ActionManager.prototype.custom_events, {
+ execute_action: '_onExecuteAction',
+ switch_view: '_onSwitchView',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to handle the case of lazy-loaded controllers, which may be the
+ * last controller in the stack, but which should not be considered as
+ * current controller as they don't have an alive widget.
+ *
+ * Note: this function assumes that there can be at most one lazy loaded
+ * controller in the stack
+ *
+ * @override
+ */
+ getCurrentController: function () {
+ var currentController = this._super.apply(this, arguments);
+ var action = currentController && this.actions[currentController.actionID];
+ if (action && action.type === 'ir.actions.act_window' && !currentController.widget) {
+ var lastControllerID = this.controllerStack.pop();
+ currentController = this._super.apply(this, arguments);
+ this.controllerStack.push(lastControllerID);
+ }
+ return currentController;
+ },
+ /**
+ * Overrides to handle the case where an 'ir.actions.act_window' has to be
+ * loaded.
+ *
+ * @override
+ * @param {Object} state
+ * @param {integer|string} [state.action] the ID or xml ID of the action to
+ * execute
+ * @param {integer} [state.active_id]
+ * @param {string} [state.active_ids]
+ * @param {integer} [state.id]
+ * @param {integer} [state.view_id=false]
+ * @param {string} [state.view_type]
+ */
+ loadState: function (state) {
+ var _super = this._super.bind(this);
+ var action;
+ var options = {
+ clear_breadcrumbs: true,
+ pushState: false,
+ };
+ if (state.action) {
+ var currentController = this.getCurrentController();
+ var currentAction = currentController && this.actions[currentController.actionID];
+ if (currentAction && currentAction.id === state.action &&
+ currentAction.type === 'ir.actions.act_window') {
+ // the action to load is already the current one, so update it
+ this._closeDialog(true); // there may be a currently opened dialog, close it
+ var viewOptions = {currentId: state.id};
+ var viewType = state.view_type || currentController.viewType;
+ return this._switchController(currentAction, viewType, viewOptions);
+ } else if (!core.action_registry.contains(state.action)) {
+ // the action to load isn't the current one, so execute it
+ var context = {};
+ if (state.active_id) {
+ context.active_id = state.active_id;
+ }
+ if (state.active_ids) {
+ // jQuery's BBQ plugin does some parsing on values that are valid integers
+ // which means that if there's only one item, it will do parseInt() on it,
+ // otherwise it will keep the comma seperated list as string
+ context.active_ids = state.active_ids.toString().split(',').map(function (id) {
+ return parseInt(id, 10) || id;
+ });
+ } else if (state.active_id) {
+ context.active_ids = [state.active_id];
+ }
+ context.params = state;
+ action = state.action;
+ options = _.extend(options, {
+ additional_context: context,
+ resID: state.id || undefined, // empty string with bbq
+ viewType: state.view_type,
+ });
+ }
+ } else if (state.model && state.id) {
+ action = {
+ res_model: state.model,
+ res_id: state.id,
+ type: 'ir.actions.act_window',
+ views: [[state.view_id || false, 'form']],
+ };
+ } else if (state.model && state.view_type) {
+ // this is a window action on a multi-record view, so restore it
+ // from the session storage
+ var storedAction = this.call('session_storage', 'getItem', 'current_action');
+ var lastAction = JSON.parse(storedAction || '{}');
+ if (lastAction.res_model === state.model) {
+ action = lastAction;
+ options.viewType = state.view_type;
+ }
+ }
+ if (action) {
+ return this.doAction(action, options);
+ }
+ return _super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Instantiates the controller for a given action and view type, and adds it
+ * to the list of controllers in the action.
+ *
+ * @private
+ * @param {Object} action
+ * @param {AbstractController[]} action.controllers the already created
+ * controllers for this action
+ * @param {Object[]} action.views the views available for the action, each
+ * one containing its fieldsView
+ * @param {Object} action.env
+ * @param {string} viewType
+ * @param {Object} [viewOptions] dict of options passed to the initialization
+ * of the controller's widget
+ * @param {Object} [options]
+ * @param {string} [options.controllerID=false] when the controller has
+ * previously been lazy-loaded, we want to keep its jsID when loading it
+ * @param {integer} [options.index=0] the controller's index in the stack
+ * @param {boolean} [options.lazy=false] set to true to differ the
+ * initialization of the controller's widget
+ * @returns {Promise<Object>} resolved with the created controller
+ */
+ _createViewController: function (action, viewType, viewOptions, options) {
+ var self = this;
+ var viewDescr = _.findWhere(action.views, {type: viewType});
+ if (!viewDescr) {
+ // the requested view type isn't specified in the action (e.g.
+ // action with list view only, user clicks on a row in the list, it
+ // tries to switch to form view)
+ return Promise.reject();
+ }
+
+ options = options || {};
+ var index = options.index || 0;
+ var controllerID = options.controllerID || _.uniqueId('controller_');
+ var controller = {
+ actionID: action.jsID,
+ className: 'o_act_window', // used to remove the padding in dialogs
+ index: index,
+ jsID: controllerID,
+ viewType: viewType,
+ };
+ Object.defineProperty(controller, 'title', {
+ get: function () {
+ // handle the case where the widget is lazy loaded
+ return controller.widget ?
+ controller.widget.getTitle() :
+ (action.display_name || action.name);
+ },
+ });
+ this.controllers[controllerID] = controller;
+
+ if (!options.lazy) {
+ // build the view options from different sources
+ var flags = action.flags || {};
+ viewOptions = _.extend({}, flags, flags[viewType], viewOptions, {
+ action: action,
+ breadcrumbs: this._getBreadcrumbs(this.controllerStack.slice(0, index)),
+ // pass the controllerID to the views as an hook for further
+ // communication with trigger_up
+ controllerID: controllerID,
+ });
+ var rejection;
+ var view = new viewDescr.Widget(viewDescr.fieldsView, viewOptions);
+ var def = new Promise(function (resolve, reject) {
+ rejection = reject;
+ view.getController(self).then(function (widget) {
+ if (def.rejected) {
+ // the promise has been rejected meanwhile, meaning that
+ // the action has been removed, so simply destroy the widget
+ widget.destroy();
+ } else {
+ controller.widget = widget;
+ resolve(controller);
+ }
+ }).guardedCatch(reject);
+ });
+ // Need to define an reject property to call it into _destroyWindowAction
+ def.reject = rejection;
+ def.guardedCatch(function () {
+ def.rejected = true;
+ delete self.controllers[controllerID];
+ });
+ action.controllers[viewType] = def;
+ } else {
+ action.controllers[viewType] = Promise.resolve(controller);
+ }
+ return action.controllers[viewType];
+ },
+ /**
+ * Destroys the controllers and search view of a given action of type
+ * 'ir.actions.act_window'.
+ *
+ * @private
+ * @param {Object} action
+ */
+ _destroyWindowAction: function (action) {
+ var self = this;
+ for (var c in action.controllers) {
+ var controllerDef = action.controllers[c];
+ controllerDef.then(function (controller) {
+ delete self.controllers[controller.jsID];
+ if (controller.widget) {
+ controller.widget.destroy();
+ }
+ });
+ // If controllerDef is not resolved yet, reject it so that the
+ // controller will be correctly destroyed as soon as it'll be ready,
+ // and its reference will be removed. Lazy-loaded controllers do
+ // not have a reject function on their promise
+ if (controllerDef.reject) {
+ controllerDef.reject();
+ }
+ }
+ },
+ /**
+ * Executes actions of type 'ir.actions.act_window'.
+ *
+ * @private
+ * @param {Object} action the description of the action to execute
+ * @param {Array} action.views list of tuples [viewID, viewType]
+ * @param {Object} options @see doAction for details
+ * @param {integer} [options.resID] the current res ID
+ * @param {string} [options.viewType] the view to open
+ * @returns {Promise} resolved when the action is appended to the DOM
+ */
+ _executeWindowAction: function (action, options) {
+ var self = this;
+ return this.dp.add(this._loadViews(action)).then(function (fieldsViews) {
+ var views = self._generateActionViews(action, fieldsViews);
+ action._views = action.views; // save the initial attribute
+ action.views = views;
+ action.controlPanelFieldsView = fieldsViews.search;
+ action.controllers = {};
+
+ // select the current view to display, and optionally the main view
+ // of the action which will be lazyloaded
+ var curView = options.viewType && _.findWhere(views, {type: options.viewType});
+ var lazyView;
+ if (curView) {
+ if (!curView.multiRecord && views[0].multiRecord) {
+ lazyView = views[0];
+ }
+ } else {
+ curView = views[0];
+ }
+
+ // use mobile-friendly view by default in mobile, if possible
+ if (config.device.isMobile) {
+ if (!curView.isMobileFriendly) {
+ curView = self._findMobileView(views, curView.multiRecord) || curView;
+ }
+ if (lazyView && !lazyView.isMobileFriendly) {
+ lazyView = self._findMobileView(views, lazyView.multiRecord) || lazyView;
+ }
+ }
+
+ var lazyViewDef;
+ var lazyControllerID;
+ if (lazyView) {
+ // if the main view is lazy-loaded, its (lazy-loaded) controller is inserted
+ // into the controller stack (so that breadcrumbs can be correctly computed),
+ // so we force clear_breadcrumbs to false so that it won't be removed when the
+ // current controller will be inserted afterwards
+ options.clear_breadcrumbs = false;
+ // this controller being lazy-loaded, this call is actually sync
+ lazyViewDef = self._createViewController(action, lazyView.type, {}, {lazy: true})
+ .then(function (lazyLoadedController) {
+ lazyControllerID = lazyLoadedController.jsID;
+ self.controllerStack.push(lazyLoadedController.jsID);
+ });
+ }
+ return self.dp.add(Promise.resolve(lazyViewDef))
+ .then(function () {
+ var viewOptions = {
+ controllerState: options.controllerState,
+ currentId: options.resID,
+ };
+ var curViewDef = self._createViewController(action, curView.type, viewOptions, {
+ index: self._getControllerStackIndex(options),
+ });
+ return self.dp.add(curViewDef);
+ })
+ .then(function (controller) {
+ action.controllerID = controller.jsID;
+ return self._executeAction(action, options);
+ })
+ .guardedCatch(function () {
+ if (lazyControllerID) {
+ var index = self.controllerStack.indexOf(lazyControllerID);
+ self.controllerStack = self.controllerStack.slice(0, index);
+ }
+ self._destroyWindowAction(action);
+ });
+ });
+ },
+ /**
+ * Helper function to find the first mobile-friendly view, if any.
+ *
+ * @private
+ * @param {Array} views an array of views
+ * @param {boolean} multiRecord set to true iff we search for a multiRecord
+ * view
+ * @returns {Object|undefined} a mobile-friendly view of the requested
+ * multiRecord type, undefined if there is no such view
+ */
+ _findMobileView: function (views, multiRecord) {
+ return _.findWhere(views, {
+ isMobileFriendly: true,
+ multiRecord: multiRecord,
+ });
+ },
+ /**
+ * Generate the description of the views of a given action. For each view,
+ * it generates a dict with information like the fieldsView, the view type,
+ * the Widget to use...
+ *
+ * @private
+ * @param {Object} action
+ * @param {Object} fieldsViews
+ * @returns {Object}
+ */
+ _generateActionViews: function (action, fieldsViews) {
+ var views = [];
+ _.each(action.views, function (view) {
+ var viewType = view[1];
+ var fieldsView = fieldsViews[viewType];
+ var parsedXML = new DOMParser().parseFromString(fieldsView.arch, "text/xml");
+ var key = parsedXML.documentElement.getAttribute('js_class');
+ var View = view_registry.get(key || viewType);
+ if (View) {
+ views.push({
+ accessKey: View.prototype.accessKey || View.prototype.accesskey,
+ displayName: View.prototype.display_name,
+ fieldsView: fieldsView,
+ icon: View.prototype.icon,
+ isMobileFriendly: View.prototype.mobile_friendly,
+ multiRecord: View.prototype.multi_record,
+ type: viewType,
+ viewID: view[0],
+ Widget: View,
+ });
+ } else if (config.isDebug('assets')) {
+ console.log("View type '" + viewType + "' is not present in the view registry.");
+ }
+ });
+ return views;
+ },
+ /**
+ * Overrides to add specific information for controllers from actions of
+ * type 'ir.actions.act_window', like the res_model and the view_type.
+ *
+ * @override
+ * @private
+ */
+ _getControllerState: function (controllerID) {
+ var state = this._super.apply(this, arguments);
+ var controller = this.controllers[controllerID];
+ var action = this.actions[controller.actionID];
+ if (action.type === 'ir.actions.act_window') {
+ state.model = action.res_model;
+ state.view_type = controller.viewType;
+ }
+ return state;
+ },
+ /**
+ * Overrides to handle the 'ir.actions.act_window' actions.
+ *
+ * @override
+ * @private
+ */
+ _handleAction: function (action, options) {
+ if (action.type === 'ir.actions.act_window') {
+ return this._executeWindowAction(action, options);
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Loads the fields_views and fields for the given action.
+ *
+ * @private
+ * @param {Object} action
+ * @returns {Promise}
+ */
+ _loadViews: function (action) {
+ var inDialog = action.target === 'new';
+ var inline = action.target === 'inline';
+ var options = {
+ action_id: action.id,
+ toolbar: !inDialog && !inline,
+ };
+ var views = action.views.slice();
+ if (!inline && !(inDialog && action.views[0][1] === 'form')) {
+ options.load_filters = true;
+ var searchviewID = action.search_view_id && action.search_view_id[0];
+ views.push([searchviewID || false, 'search']);
+ }
+ return this.loadViews(action.res_model, action.context, views, options);
+ },
+ /**
+ * Overrides to handle the case of 'ir.actions.act_window' actions, i.e.
+ * destroys all controllers associated to the given action, and its search
+ * view.
+ *
+ * @override
+ * @private
+ */
+ _removeAction: function (actionID) {
+ var action = this.actions[actionID];
+ if (action.type === 'ir.actions.act_window') {
+ delete this.actions[action.jsID];
+ this._destroyWindowAction(action);
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+ /**
+ * Overrides to handle the case where the controller to restore is from an
+ * 'ir.actions.act_window' action. In this case, only the controllers
+ * stacked over the one to restore *that are not from the same action* are
+ * destroyed.
+ * For instance, when going back to the list controller from a form
+ * controller of the same action using the breadcrumbs, the form controller
+ * isn't destroyed, as it might be reused in the future.
+ *
+ * @override
+ * @private
+ */
+ _restoreController: function (controllerID) {
+ var self = this;
+ var controller = this.controllers[controllerID];
+ var action = this.actions[controller.actionID];
+ if (action.type === 'ir.actions.act_window') {
+ return this.clearUncommittedChanges().then(function () {
+ // AAB: this will be done directly in AbstractAction's restore
+ // function
+ var def = Promise.resolve();
+ if (action.on_reverse_breadcrumb) {
+ def = action.on_reverse_breadcrumb();
+ }
+ return Promise.resolve(def).then(function () {
+ return self._switchController(action, controller.viewType);
+ });
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Handles the switch from a controller to another (either inside the same
+ * window action, or from a window action to another using the breadcrumbs).
+ *
+ * @private
+ * @param {Object} controller the controller to switch to
+ * @param {Object} [viewOptions]
+ * @return {Promise} resolved when the new controller is in the DOM
+ */
+ _switchController: function (action, viewType, viewOptions) {
+ var self = this;
+ var view = _.findWhere(action.views, {type: viewType});
+ if (!view) {
+ // can't switch to an unknown view
+ return Promise.reject();
+ }
+
+ var currentController = this.getCurrentController();
+ var index;
+ if (currentController.actionID !== action.jsID) {
+ // the requested controller is from another action, so we went back
+ // to a previous action using the breadcrumbs
+ var controller = _.findWhere(this.controllers, {
+ actionID: action.jsID,
+ viewType: viewType,
+ });
+ index = _.indexOf(this.controllerStack, controller.jsID);
+ } else {
+ // the requested controller is from the same action as the current
+ // one, so we either
+ // 1) go one step back from a mono record view to a multi record
+ // one using the breadcrumbs
+ // 2) or we switched from a view to another using the view
+ // switcher
+ // 3) or we opened a record from a multi record view
+ if (view.multiRecord) {
+ // cases 1) and 2) (with multi record views): replace the first
+ // controller linked to the same action in the stack
+ index = _.findIndex(this.controllerStack, function (controllerID) {
+ return self.controllers[controllerID].actionID === action.jsID;
+ });
+ } else if (!_.findWhere(action.views, {type: currentController.viewType}).multiRecord) {
+ // case 2) (with mono record views): replace the last
+ // controller by the new one if they are from the same action
+ // and if they both are mono record
+ index = this.controllerStack.length - 1;
+ } else {
+ // case 3): insert the controller on the top of the controller
+ // stack
+ index = this.controllerStack.length;
+ }
+ }
+
+ var newController = function (controllerID) {
+ var options = {
+ controllerID: controllerID,
+ index: index,
+ };
+ return self
+ ._createViewController(action, viewType, viewOptions, options)
+ .then(function (controller) {
+ return self._startController(controller);
+ });
+ };
+
+ var controllerDef = action.controllers[viewType];
+ if (controllerDef) {
+ controllerDef = controllerDef.then(function (controller) {
+ if (!controller.widget) {
+ // lazy loaded -> load it now (with same jsID)
+ return newController(controller.jsID);
+ } else {
+ return Promise.resolve(controller.widget.willRestore()).then(function () {
+ viewOptions = _.extend({}, viewOptions, {
+ breadcrumbs: self._getBreadcrumbs(self.controllerStack.slice(0, index)),
+ shouldUpdateSearchComponents: true,
+ });
+ return controller.widget.reload(viewOptions).then(function () {
+ return controller;
+ });
+ });
+ }
+ }, function () {
+ // if the controllerDef is rejected, it probably means that the js
+ // code or the requests made to the server crashed. In that case,
+ // if we reuse the same promise, then the switch to the view is
+ // definitely blocked. We want to use a new controller, even though
+ // it is very likely that it will recrash again. At least, it will
+ // give more feedback to the user, and it could happen that one
+ // record crashes, but not another.
+ return newController();
+ });
+ } else {
+ controllerDef = newController();
+ }
+
+ return this.dp.add(controllerDef).then(function (controller) {
+ return self._pushController(controller);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handler for event 'execute_action', which is typically called when a
+ * button is clicked. The button may be of type 'object' (call a given
+ * method of a given model) or 'action' (execute a given action).
+ * Alternatively, the button may have the attribute 'special', and in this
+ * case an 'ir.actions.act_window_close' is executed.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data.action_data typically, the html attributes of the
+ * button extended with additional information like the context
+ * @param {Object} [ev.data.action_data.special=false]
+ * @param {Object} [ev.data.action_data.type] 'object' or 'action', if set
+ * @param {Object} ev.data.env
+ * @param {function} [ev.data.on_closed]
+ * @param {function} [ev.data.on_fail]
+ * @param {function} [ev.data.on_success]
+ */
+ _onExecuteAction: function (ev) {
+ ev.stopPropagation();
+ var self = this;
+ var actionData = ev.data.action_data;
+ var env = ev.data.env;
+ var context = new Context(env.context, actionData.context || {});
+ var recordID = env.currentID || null; // pyUtils handles null value, not undefined
+ var def;
+
+ // determine the action to execute according to the actionData
+ if (actionData.special) {
+ def = Promise.resolve({
+ type: 'ir.actions.act_window_close',
+ infos: { special: true },
+ });
+ } else if (actionData.type === 'object') {
+ // call a Python Object method, which may return an action to execute
+ var args = recordID ? [[recordID]] : [env.resIDs];
+ if (actionData.args) {
+ try {
+ // warning: quotes and double quotes problem due to json and xml clash
+ // maybe we should force escaping in xml or do a better parse of the args array
+ var additionalArgs = JSON.parse(actionData.args.replace(/'/g, '"'));
+ args = args.concat(additionalArgs);
+ } catch (e) {
+ console.error("Could not JSON.parse arguments", actionData.args);
+ }
+ }
+ def = this._rpc({
+ route: '/web/dataset/call_button',
+ params: {
+ args: args,
+ kwargs: {context: context.eval()},
+ method: actionData.name,
+ model: env.model,
+ },
+ });
+ } else if (actionData.type === 'action') {
+ // execute a given action, so load it first
+ def = this._loadAction(actionData.name, _.extend(pyUtils.eval('context', context), {
+ active_model: env.model,
+ active_ids: env.resIDs,
+ active_id: recordID,
+ }));
+ } else {
+ def = Promise.reject();
+ }
+
+ // use the DropPrevious to prevent from executing the handler if another
+ // request (doAction, switchView...) has been done meanwhile ; execute
+ // the fail handler if the 'call_button' or 'loadAction' failed but not
+ // if the request failed due to the DropPrevious,
+ def.guardedCatch(ev.data.on_fail);
+ this.dp.add(def).then(function (action) {
+ // show effect if button have effect attribute
+ // rainbowman can be displayed from two places: from attribute on a button or from python
+ // code below handles the first case i.e 'effect' attribute on button.
+ var effect = false;
+ if (actionData.effect) {
+ effect = pyUtils.py_eval(actionData.effect);
+ }
+
+ if (action && action.constructor === Object) {
+ // filter out context keys that are specific to the current action, because:
+ // - wrong default_* and search_default_* values won't give the expected result
+ // - wrong group_by values will fail and forbid rendering of the destination view
+ var ctx = new Context(
+ _.object(_.reject(_.pairs(env.context), function (pair) {
+ return pair[0].match('^(?:(?:default_|search_default_|show_).+|' +
+ '.+_view_ref|group_by|group_by_no_leaf|active_id|' +
+ 'active_ids|orderedBy)$') !== null;
+ }))
+ );
+ ctx.add(actionData.context || {});
+ ctx.add({active_model: env.model});
+ if (recordID) {
+ ctx.add({
+ active_id: recordID,
+ active_ids: [recordID],
+ });
+ }
+ ctx.add(action.context || {});
+ action.context = ctx;
+ // in case an effect is returned from python and there is already an effect
+ // attribute on the button, the priority is given to the button attribute
+ action.effect = effect || action.effect;
+ } else {
+ // if action doesn't return anything, but there is an effect
+ // attribute on the button, display rainbowman
+ action = {
+ effect: effect,
+ type: 'ir.actions.act_window_close',
+ };
+ }
+ var options = {on_close: ev.data.on_closed};
+ if (config.device.isMobile && actionData.mobile) {
+ options = Object.assign({}, options, actionData.mobile);
+ }
+ return self.doAction(action, options).then(ev.data.on_success, ev.data.on_fail);
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {string} ev.data.controllerID the id of the controller that
+ * triggered the event
+ * @param {string} ev.data.viewType the type of view to switch to
+ * @param {integer} [ev.data.res_id] the id of the record to open (for
+ * mono-record views)
+ * @param {mode} [ev.data.mode] the mode to open, i.e. 'edit' or 'readonly'
+ * (only relevant for form views)
+ */
+ _onSwitchView: function (ev) {
+ ev.stopPropagation();
+ const viewType = ev.data.view_type;
+ const currentController = this.getCurrentController();
+ if (currentController.jsID === ev.data.controllerID) {
+ // only switch to the requested view if the controller that
+ // triggered the request is the current controller
+ const action = this.actions[currentController.actionID];
+ const currentControllerState = currentController.widget.exportState();
+ action.controllerState = _.extend({}, action.controllerState, currentControllerState);
+ const options = {
+ controllerState: action.controllerState,
+ currentId: ev.data.res_id,
+ };
+ if (ev.data.mode) {
+ options.mode = ev.data.mode;
+ }
+ this._switchController(action, viewType, options);
+ }
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/chrome/action_manager_report.js b/addons/web/static/src/js/chrome/action_manager_report.js
new file mode 100644
index 00000000..b3c05796
--- /dev/null
+++ b/addons/web/static/src/js/chrome/action_manager_report.js
@@ -0,0 +1,203 @@
+odoo.define('web.ReportActionManager', function (require) {
+"use strict";
+
+/**
+ * The purpose of this file is to add the support of Odoo actions of type
+ * 'ir.actions.report' to the ActionManager.
+ */
+
+var ActionManager = require('web.ActionManager');
+var core = require('web.core');
+var framework = require('web.framework');
+var session = require('web.session');
+
+
+var _t = core._t;
+var _lt = core._lt;
+
+// Messages that might be shown to the user dependening on the state of wkhtmltopdf
+var link = '<br><br><a href="http://wkhtmltopdf.org/" target="_blank">wkhtmltopdf.org</a>';
+var WKHTMLTOPDF_MESSAGES = {
+ broken: _lt('Your installation of Wkhtmltopdf seems to be broken. The report will be shown ' +
+ 'in html.') + link,
+ install: _lt('Unable to find Wkhtmltopdf on this system. The report will be shown in ' +
+ 'html.') + link,
+ upgrade: _lt('You should upgrade your version of Wkhtmltopdf to at least 0.12.0 in order to ' +
+ 'get a correct display of headers and footers as well as support for ' +
+ 'table-breaking between pages.') + link,
+ workers: _lt('You need to start Odoo with at least two workers to print a pdf version of ' +
+ 'the reports.'),
+};
+
+ActionManager.include({
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Downloads a PDF report for the given url. It blocks the UI during the
+ * report generation and download.
+ *
+ * @param {string} url
+ * @returns {Promise} resolved when the report has been downloaded ;
+ * rejected if something went wrong during the report generation
+ */
+ _downloadReport: function (url) {
+ var self = this;
+ framework.blockUI();
+ return new Promise(function (resolve, reject) {
+ var type = 'qweb-' + url.split('/')[2];
+ var blocked = !session.get_file({
+ url: '/report/download',
+ data: {
+ data: JSON.stringify([url, type]),
+ context: JSON.stringify(session.user_context),
+ },
+ success: resolve,
+ error: (error) => {
+ self.call('crash_manager', 'rpc_error', error);
+ reject();
+ },
+ complete: framework.unblockUI,
+ });
+ if (blocked) {
+ // AAB: this check should be done in get_file service directly,
+ // should not be the concern of the caller (and that way, get_file
+ // could return a promise)
+ var message = _t('A popup window with your report was blocked. You ' +
+ 'may need to change your browser settings to allow ' +
+ 'popup windows for this page.');
+ self.do_warn(_t('Warning'), message, true);
+ }
+ });
+ },
+
+ /**
+ * Launch download action of the report
+ *
+ * @private
+ * @param {Object} action the description of the action to execute
+ * @param {Object} options @see doAction for details
+ * @returns {Promise} resolved when the action has been executed
+ */
+ _triggerDownload: function (action, options, type){
+ var self = this;
+ var reportUrls = this._makeReportUrls(action);
+ return this._downloadReport(reportUrls[type]).then(function () {
+ if (action.close_on_report_download) {
+ var closeAction = { type: 'ir.actions.act_window_close' };
+ return self.doAction(closeAction, _.pick(options, 'on_close'));
+ } else {
+ return options.on_close();
+ }
+ });
+ },
+ /**
+ * Executes actions of type 'ir.actions.report'.
+ *
+ * @private
+ * @param {Object} action the description of the action to execute
+ * @param {Object} options @see doAction for details
+ * @returns {Promise} resolved when the action has been executed
+ */
+ _executeReportAction: function (action, options) {
+ var self = this;
+
+ if (action.report_type === 'qweb-html') {
+ return this._executeReportClientAction(action, options);
+ } else if (action.report_type === 'qweb-pdf') {
+ // check the state of wkhtmltopdf before proceeding
+ return this.call('report', 'checkWkhtmltopdf').then(function (state) {
+ // display a notification according to wkhtmltopdf's state
+ if (state in WKHTMLTOPDF_MESSAGES) {
+ self.do_notify(_t('Report'), WKHTMLTOPDF_MESSAGES[state], true);
+ }
+
+ if (state === 'upgrade' || state === 'ok') {
+ // trigger the download of the PDF report
+ return self._triggerDownload(action, options, 'pdf');
+ } else {
+ // open the report in the client action if generating the PDF is not possible
+ return self._executeReportClientAction(action, options);
+ }
+ });
+ } else if (action.report_type === 'qweb-text') {
+ return self._triggerDownload(action, options, 'text');
+ } else {
+ console.error("The ActionManager can't handle reports of type " +
+ action.report_type, action);
+ return Promise.reject();
+ }
+ },
+ /**
+ * Executes the report client action, either because the report_type is
+ * 'qweb-html', or because the PDF can't be generated by wkhtmltopdf (in
+ * the case of 'qweb-pdf' reports).
+ *
+ * @param {Object} action
+ * @param {Object} options
+ * @returns {Promise} resolved when the client action has been executed
+ */
+ _executeReportClientAction: function (action, options) {
+ var urls = this._makeReportUrls(action);
+ var clientActionOptions = _.extend({}, options, {
+ context: action.context,
+ data: action.data,
+ display_name: action.display_name,
+ name: action.name,
+ report_file: action.report_file,
+ report_name: action.report_name,
+ report_url: urls.html,
+ });
+ return this.doAction('report.client_action', clientActionOptions);
+ },
+ /**
+ * Overrides to handle the 'ir.actions.report' actions.
+ *
+ * @override
+ * @private
+ */
+ _handleAction: function (action, options) {
+ if (action.type === 'ir.actions.report') {
+ return this._executeReportAction(action, options);
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Generates an object containing the report's urls (as value) for every
+ * qweb-type we support (as key). It's convenient because we may want to use
+ * another report's type at some point (for example, when `qweb-pdf` is not
+ * available).
+ *
+ * @param {Object} action
+ * @returns {Object}
+ */
+ _makeReportUrls: function (action) {
+ var reportUrls = {
+ html: '/report/html/' + action.report_name,
+ pdf: '/report/pdf/' + action.report_name,
+ text: '/report/text/' + action.report_name,
+ };
+ // We may have to build a query string with `action.data`. It's the place
+ // were report's using a wizard to customize the output traditionally put
+ // their options.
+ if (_.isUndefined(action.data) || _.isNull(action.data) ||
+ (_.isObject(action.data) && _.isEmpty(action.data))) {
+ if (action.context.active_ids) {
+ var activeIDsPath = '/' + action.context.active_ids.join(',');
+ reportUrls = _.mapObject(reportUrls, function (value) {
+ return value += activeIDsPath;
+ });
+ }
+ reportUrls.html += '?context=' + encodeURIComponent(JSON.stringify(session.user_context));
+ } else {
+ var serializedOptionsPath = '?options=' + encodeURIComponent(JSON.stringify(action.data));
+ serializedOptionsPath += '&context=' + encodeURIComponent(JSON.stringify(action.context));
+ reportUrls = _.mapObject(reportUrls, function (value) {
+ return value += serializedOptionsPath;
+ });
+ }
+ return reportUrls;
+ },
+});
+});
diff --git a/addons/web/static/src/js/chrome/action_mixin.js b/addons/web/static/src/js/chrome/action_mixin.js
new file mode 100644
index 00000000..82feba04
--- /dev/null
+++ b/addons/web/static/src/js/chrome/action_mixin.js
@@ -0,0 +1,235 @@
+odoo.define('web.ActionMixin', function (require) {
+ "use strict";
+
+ /**
+ * We define here the ActionMixin, the generic notion of action (from the point
+ * of view of the web client). In short, an action is a widget which controls
+ * the main part of the screen (everything below the navbar).
+ *
+ * More precisely, the action manager is the component that coordinates a stack
+ * of actions. Whenever the user navigates in the interface, switches views,
+ * open different menus, the action manager creates/updates/destroys special
+ * widgets which implements the ActionMixin. These actions need to answer to a
+ * standardised API, which is the reason for this mixin.
+ *
+ * In practice, most actions are view controllers (coming from an
+ * ir.action.act_window). However, some actions are 'client actions'. They
+ * also need to implement the ActionMixin for a better cooperation with the
+ * action manager.
+ *
+ * @module web.ActionMixin
+ * @extends WidgetAdapterMixin
+ */
+
+ const core = require('web.core');
+ const { WidgetAdapterMixin } = require('web.OwlCompatibility');
+
+ const ActionMixin = Object.assign({}, WidgetAdapterMixin, {
+ template: 'Action',
+
+ /**
+ * The action mixin assumes that it is rendered with the 'Action' template.
+ * This template has a special zone ('.o_content') where the content should
+ * be added. Actions that want to automatically render a template there
+ * should define the contentTemplate key. In short, client actions should
+ * probably define a contentTemplate key, and not a template key.
+ */
+ contentTemplate: null,
+
+ /**
+ * Events built by and managed by Odoo Framework
+ *
+ * It is expected that any Widget Class implementing this mixin
+ * will also implement the ParentedMixin which actually manages those
+ */
+ custom_events: {
+ get_controller_query_params: '_onGetOwnedQueryParams',
+ },
+
+ /**
+ * If an action wants to use a control panel, it will be created and
+ * registered in this _controlPanel key (the widget). The way this control
+ * panel is created is up to the implementation (so, view controllers or
+ * client actions may have different needs).
+ *
+ * Note that most of the time, this key should be set by the framework, not
+ * by the code of the client action.
+ */
+ _controlPanel: null,
+
+ /**
+ * String containing the title of the client action (which may be needed to
+ * display in the breadcrumbs zone of the control panel).
+ *
+ * @see _setTitle
+ */
+ _title: '',
+
+ /**
+ * @override
+ */
+ renderElement: function () {
+ this._super.apply(this, arguments);
+ if (this.contentTemplate) {
+ const content = core.qweb.render(this.contentTemplate, { widget: this });
+ this.$('.o_content').append(content);
+ }
+ },
+
+ /**
+ * Called by the action manager when action is restored (typically, when
+ * the user clicks on the action in the breadcrumb)
+ *
+ * @returns {Promise|undefined}
+ */
+ willRestore: function () { },
+
+ //---------------------------------------------------------------------
+ // Public
+ //---------------------------------------------------------------------
+
+ /**
+ * In some situations, we need confirmation from the controller that the
+ * current state can be destroyed without prejudice to the user. For
+ * example, if the user has edited a form, maybe we should ask him if we
+ * can discard all his changes when we switch to another action. In that
+ * case, the action manager will call this method. If the returned
+ * promise is successfully resolved, then we can destroy the current action,
+ * otherwise, we need to stop.
+ *
+ * @returns {Promise} resolved if the action can be removed, rejected
+ * otherwise
+ */
+ canBeRemoved: function () {
+ return Promise.resolve();
+ },
+
+ /**
+ * This function is called when the current state of the action
+ * should be known. For instance, if the action is a view controller,
+ * this may be useful to reinstantiate a view in the same state.
+ *
+ * Typically the state can (and should) be encoded in a query object of
+ * the form::
+ *
+ * {
+ * context: {...},
+ * groupBy: [...],
+ * domain = [...],
+ * orderedBy = [...],
+ * }
+ *
+ * where the context key can contain many information.
+ * This method is mainly called during the creation of a custom filter.
+ *
+ * @returns {Object}
+ */
+ getOwnedQueryParams: function () {
+ return {};
+ },
+
+ /**
+ * Returns a serializable state that will be pushed in the URL by
+ * the action manager, allowing the action to be restarted correctly
+ * upon refresh. This function should be overriden to add extra information.
+ * Note that some keys are reserved by the framework and will thus be
+ * ignored ('action', 'active_id', 'active_ids' and 'title', for all
+ * actions, and 'model' and 'view_type' for act_window actions).
+ *
+ * @returns {Object}
+ */
+ getState: function () {
+ return {};
+ },
+
+ /**
+ * Returns a title that may be displayed in the breadcrumb area. For
+ * example, the name of the record (for a form view). This is actually
+ * important for the action manager: this is the way it is able to give
+ * the proper titles for other actions.
+ *
+ * @returns {string}
+ */
+ getTitle: function () {
+ return this._title;
+ },
+
+ /**
+ * Renders the buttons to append, in most cases, to the control panel (in
+ * the bottom left corner). When the action is rendered in a dialog, those
+ * buttons might be moved to the dialog's footer.
+ *
+ * @param {jQuery Node} $node
+ */
+ renderButtons: function ($node) { },
+
+ /**
+ * Method used to update the widget buttons state.
+ */
+ updateButtons: function () { },
+
+ /**
+ * The parameter newProps is used to update the props of
+ * the controlPanelWrapper before render it. The key 'cp_content'
+ * is not a prop of the control panel itself. One should if possible use
+ * the slot mechanism.
+ *
+ * @param {Object} [newProps={}]
+ * @returns {Promise}
+ */
+ updateControlPanel: async function (newProps = {}) {
+ if (!this.withControlPanel && !this.hasControlPanel) {
+ return;
+ }
+ const props = Object.assign({}, newProps); // Work with a clean new object
+ if ('title' in props) {
+ this._setTitle(props.title);
+ this.controlPanelProps.title = this.getTitle();
+ delete props.title;
+ }
+ if ('cp_content' in props) {
+ // cp_content has been updated: refresh it.
+ this.controlPanelProps.cp_content = Object.assign({},
+ this.controlPanelProps.cp_content,
+ props.cp_content,
+ );
+ delete props.cp_content;
+ }
+ // Update props state
+ Object.assign(this.controlPanelProps, props);
+ return this._controlPanelWrapper.update(this.controlPanelProps);
+ },
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} title
+ */
+ _setTitle: function (title) {
+ this._title = title;
+ },
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * FIXME: this logic should be rethought
+ *
+ * Handles a context request: provides to the caller the state of the
+ * current controller.
+ *
+ * @private
+ * @param {function} callback used to send the requested state
+ */
+ _onGetOwnedQueryParams: function (callback) {
+ const state = this.getOwnedQueryParams();
+ callback(state || {});
+ },
+ });
+
+ return ActionMixin;
+});
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 00000000..b7e057f8
--- /dev/null
+++ b/addons/web/static/src/js/chrome/apps_menu.js
@@ -0,0 +1,102 @@
+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[]}
+ */
+ getApps: function () {
+ return this._apps;
+ },
+ /**
+ * Open the first app in the list of apps. Returns whether one was found.
+ *
+ * @returns {Boolean}
+ */
+ openFirstApp: function () {
+ if (!this._apps.length) {
+ return false;
+ }
+ var firstApp = this._apps[0];
+ this._openApp(firstApp);
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // 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) {
+ var $oldActiveApp = this.$('.o_app.active');
+ $oldActiveApp.removeClass('active');
+ var $newActiveApp = this.$('.o_app[data-action-id="' + app.actionID + '"]');
+ $newActiveApp.addClass('active');
+ },
+
+ //--------------------------------------------------------------------------
+ // 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/keyboard_navigation_mixin.js b/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js
new file mode 100644
index 00000000..c67cb98b
--- /dev/null
+++ b/addons/web/static/src/js/chrome/keyboard_navigation_mixin.js
@@ -0,0 +1,261 @@
+odoo.define('web.KeyboardNavigationMixin', function (require) {
+ "use strict";
+ var BrowserDetection = require('web.BrowserDetection');
+ const core = require('web.core');
+
+ /**
+ * list of the key that should not be used as accesskeys. Either because we want to reserve them for a specific behavior in Odoo or
+ * because they will not work in certain browser/OS
+ */
+ var knownUnusableAccessKeys = [' ',
+ 'A', // reserved for Odoo Edit
+ 'B', // reserved for Odoo Previous Breadcrumb (Back)
+ 'C', // reserved for Odoo Create
+ 'H', // reserved for Odoo Home
+ 'J', // reserved for Odoo Discard
+ 'K', // reserved for Odoo Kanban view
+ 'L', // reserved for Odoo List view
+ 'N', // reserved for Odoo pager Next
+ 'P', // reserved for Odoo pager Previous
+ 'S', // reserved for Odoo Save
+ 'Q', // reserved for Odoo Search
+ 'E', // chrome does not support 'E' access key --> go to address bar to search google
+ 'F', // chrome does not support 'F' access key --> go to menu
+ 'D', // chrome does not support 'D' access key --> go to address bar
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' // reserved for Odoo menus
+ ];
+
+ var KeyboardNavigationMixin = {
+ events: {
+ 'keydown': '_onKeyDown',
+ 'keyup': '_onKeyUp',
+ },
+
+ /**
+ * @constructor
+ * @param {object} [options]
+ * @param {boolean} [options.autoAccessKeys=true]
+ * Whether accesskeys should be created automatically for buttons
+ * without them in the page.
+ */
+ init: function (options) {
+ this.options = Object.assign({
+ autoAccessKeys: true,
+ }, options);
+ this._areAccessKeyVisible = false;
+ this.BrowserDetection = new BrowserDetection();
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ const temp = this._hideAccessKeyOverlay.bind(this);
+ this._hideAccessKeyOverlay = () => temp();
+ window.addEventListener('blur', this._hideAccessKeyOverlay);
+ core.bus.on('click', null, this._hideAccessKeyOverlay);
+ },
+ /**
+ * @destructor
+ */
+ destroy: function () {
+ window.removeEventListener('blur', this._hideAccessKeyOverlay);
+ core.bus.off('click', null, this._hideAccessKeyOverlay);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _addAccessKeyOverlays: function () {
+ var accesskeyElements = $(document).find('[accesskey]').filter(':visible');
+ _.each(accesskeyElements, function (elem) {
+ var overlay = $(_.str.sprintf("<div class='o_web_accesskey_overlay'>%s</div>", $(elem).attr('accesskey').toUpperCase()));
+
+ var $overlayParent;
+ if (elem.tagName.toUpperCase() === "INPUT") {
+ // special case for the search input that has an access key
+ // defined. We cannot set the overlay on the input itself,
+ // only on its parent.
+ $overlayParent = $(elem).parent();
+ } else {
+ $overlayParent = $(elem);
+ }
+
+ if ($overlayParent.css('position') !== 'absolute') {
+ $overlayParent.css('position', 'relative');
+ }
+ overlay.appendTo($overlayParent);
+ });
+ },
+ /**
+ * @private
+ * @return {jQuery[]}
+ */
+ _getAllUsedAccessKeys: function () {
+ var usedAccessKeys = knownUnusableAccessKeys.slice();
+ this.$el.find('[accesskey]').each(function (_, elem) {
+ usedAccessKeys.push(elem.accessKey.toUpperCase());
+ });
+ return usedAccessKeys;
+ },
+ /**
+ * hides the overlay that shows the access keys.
+ *
+ * @private
+ * @param $parent {jQueryElemen} the parent of the DOM element to which shorcuts overlay have been added
+ * @return {undefined|jQuery}
+ */
+ _hideAccessKeyOverlay: function () {
+ this._areAccessKeyVisible = false;
+ var overlays = this.$el.find('.o_web_accesskey_overlay');
+ if (overlays.length) {
+ return overlays.remove();
+ }
+ },
+ /**
+ * @private
+ */
+ _setAccessKeyOnTopNavigation: function () {
+ this.$el.find('.o_menu_sections>li>a').each(function (number, item) {
+ item.accessKey = number + 1;
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Assign access keys to all buttons inside $el and sets an overlay to show the access key
+ * The access keys will be assigned using first the name of the button, letter by letter until we find one available,
+ * after that we will assign any available letters.
+ * Not all letters should be used as access keys, some of the should be reserved for standard odoo behavior or browser behavior
+ *
+ * @private
+ * @param keyDownEvent {jQueryKeyboardEvent} the keyboard event triggered
+ * return {undefined|false}
+ */
+ _onKeyDown: function (keyDownEvent) {
+ if ($('body.o_ui_blocked').length &&
+ (keyDownEvent.altKey || keyDownEvent.key === 'Alt') &&
+ !keyDownEvent.ctrlKey) {
+ if (keyDownEvent.preventDefault) keyDownEvent.preventDefault(); else keyDownEvent.returnValue = false;
+ if (keyDownEvent.stopPropagation) keyDownEvent.stopPropagation();
+ if (keyDownEvent.cancelBubble) keyDownEvent.cancelBubble = true;
+ return false;
+ }
+ if (!this._areAccessKeyVisible &&
+ (keyDownEvent.altKey || keyDownEvent.key === 'Alt') &&
+ !keyDownEvent.ctrlKey) {
+
+ this._areAccessKeyVisible = true;
+
+ this._setAccessKeyOnTopNavigation();
+
+ var usedAccessKey = this._getAllUsedAccessKeys();
+
+ if (this.options.autoAccessKeys) {
+ var buttonsWithoutAccessKey = this.$el.find('button.btn:visible')
+ .not('[accesskey]')
+ .not('[disabled]')
+ .not('[tabindex="-1"]');
+ _.each(buttonsWithoutAccessKey, function (elem) {
+ var buttonString = [elem.innerText, elem.title, "ABCDEFGHIJKLMNOPQRSTUVWXYZ"].join('');
+ for (var letterIndex = 0; letterIndex < buttonString.length; letterIndex++) {
+ var candidateAccessKey = buttonString[letterIndex].toUpperCase();
+ if (candidateAccessKey >= 'A' && candidateAccessKey <= 'Z' &&
+ !_.includes(usedAccessKey, candidateAccessKey)) {
+ elem.accessKey = candidateAccessKey;
+ usedAccessKey.push(candidateAccessKey);
+ break;
+ }
+ }
+ });
+ }
+
+ var elementsWithoutAriaKeyshortcut = this.$el.find('[accesskey]').not('[aria-keyshortcuts]');
+ _.each(elementsWithoutAriaKeyshortcut, function (elem) {
+ elem.setAttribute('aria-keyshortcuts', 'Alt+Shift+' + elem.accessKey);
+ });
+ this._addAccessKeyOverlays();
+ }
+ // on mac, there are a number of keys that are only accessible though the usage of
+ // the ALT key (like the @ sign in most keyboards)
+ // for them we do not facilitate the access keys, so they will need to be activated classically
+ // though Control + Alt + key (case sensitive), see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/accesskey
+ if (this.BrowserDetection.isOsMac())
+ return;
+
+ if (keyDownEvent.altKey && !keyDownEvent.ctrlKey && keyDownEvent.key.length === 1) { // we don't want to catch the Alt key down, only the characters A to Z and number keys
+ var elementWithAccessKey = [];
+ if (keyDownEvent.keyCode >= 65 && keyDownEvent.keyCode <= 90 || keyDownEvent.keyCode >= 97 && keyDownEvent.keyCode <= 122) {
+ // 65 = A, 90 = Z, 97 = a, 122 = z
+ elementWithAccessKey = document.querySelectorAll('[accesskey="' + String.fromCharCode(keyDownEvent.keyCode).toLowerCase() +
+ '"], [accesskey="' + String.fromCharCode(keyDownEvent.keyCode).toUpperCase() + '"]');
+ if (elementWithAccessKey.length) {
+ if (this.BrowserDetection.isOsMac() ||
+ !this.BrowserDetection.isBrowserChrome()) { // on windows and linux, chrome does not prevent the default of the accesskeys
+ elementWithAccessKey[0].focus();
+ elementWithAccessKey[0].click();
+ if (keyDownEvent.preventDefault) keyDownEvent.preventDefault(); else keyDownEvent.returnValue = false;
+ if (keyDownEvent.stopPropagation) keyDownEvent.stopPropagation();
+ if (keyDownEvent.cancelBubble) keyDownEvent.cancelBubble = true;
+ return false;
+ }
+ }
+ }
+ else {
+ // identify if the user has tapped on the number keys above the text keys.
+ // this is not trivial because alt is a modifier and will not input the actual number in most keyboard layouts
+ var numberKey;
+ if (keyDownEvent.originalEvent.code && keyDownEvent.originalEvent.code.indexOf('Digit') === 0) {
+ //chrome & FF have the key Digit set correctly for the numbers
+ numberKey = keyDownEvent.originalEvent.code[keyDownEvent.originalEvent.code.length - 1];
+ } else if (keyDownEvent.originalEvent.key &&
+ keyDownEvent.originalEvent.key.length === 1 &&
+ keyDownEvent.originalEvent.key >= '0' &&
+ keyDownEvent.originalEvent.key <= '9') {
+ //edge does not use 'code' on the original event, but the 'key' is set correctly
+ numberKey = keyDownEvent.originalEvent.key;
+ } else if (keyDownEvent.keyCode >= 48 && keyDownEvent.keyCode <= 57) {
+ //fallback on keyCode if both code and key are either not set or not digits
+ numberKey = keyDownEvent.keyCode - 48;
+ }
+
+ if (numberKey >= '0' && numberKey <= '9') {
+ elementWithAccessKey = document.querySelectorAll('[accesskey="' + numberKey + '"]');
+ if (elementWithAccessKey.length) {
+ elementWithAccessKey[0].click();
+ if (keyDownEvent.preventDefault) keyDownEvent.preventDefault(); else keyDownEvent.returnValue = false;
+ if (keyDownEvent.stopPropagation) keyDownEvent.stopPropagation();
+ if (keyDownEvent.cancelBubble) keyDownEvent.cancelBubble = true;
+ return false;
+ }
+ }
+ }
+ }
+ },
+ /**
+ * hides the shortcut overlays when keyup event is triggered on the ALT key
+ *
+ * @private
+ * @param keyUpEvent {jQueryKeyboardEvent} the keyboard event triggered
+ * @return {undefined|false}
+ */
+ _onKeyUp: function (keyUpEvent) {
+ if ((keyUpEvent.altKey || keyUpEvent.key === 'Alt') && !keyUpEvent.ctrlKey) {
+ this._hideAccessKeyOverlay();
+ if (keyUpEvent.preventDefault) keyUpEvent.preventDefault(); else keyUpEvent.returnValue = false;
+ if (keyUpEvent.stopPropagation) keyUpEvent.stopPropagation();
+ if (keyUpEvent.cancelBubble) keyUpEvent.cancelBubble = true;
+ return false;
+ }
+ },
+ };
+
+ return KeyboardNavigationMixin;
+
+});
diff --git a/addons/web/static/src/js/chrome/loading.js b/addons/web/static/src/js/chrome/loading.js
new file mode 100644
index 00000000..9ea48a40
--- /dev/null
+++ b/addons/web/static/src/js/chrome/loading.js
@@ -0,0 +1,80 @@
+odoo.define('web.Loading', function (require) {
+"use strict";
+
+/**
+ * Loading Indicator
+ *
+ * When the user performs an action, it is good to give him some feedback that
+ * something is currently happening. The purpose of the Loading Indicator is to
+ * display a small rectangle on the bottom right of the screen with just the
+ * text 'Loading' and the number of currently running rpcs.
+ *
+ * After a delay of 3s, if a rpc is still not completed, we also block the UI.
+ */
+
+var config = require('web.config');
+var core = require('web.core');
+var framework = require('web.framework');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+var Loading = Widget.extend({
+ template: "Loading",
+
+ init: function(parent) {
+ this._super(parent);
+ this.count = 0;
+ this.blocked_ui = false;
+ },
+ start: function() {
+ core.bus.on('rpc_request', this, this.request_call);
+ core.bus.on("rpc_response", this, this.response_call);
+ core.bus.on("rpc_response_failed", this, this.response_call);
+ },
+ destroy: function() {
+ this.on_rpc_event(-this.count);
+ this._super();
+ },
+ request_call: function() {
+ this.on_rpc_event(1);
+ },
+ response_call: function() {
+ this.on_rpc_event(-1);
+ },
+ on_rpc_event : function(increment) {
+ var self = this;
+ if (!this.count && increment === 1) {
+ // Block UI after 3s
+ this.long_running_timer = setTimeout(function () {
+ self.blocked_ui = true;
+ framework.blockUI();
+ }, 3000);
+ }
+
+ this.count += increment;
+ if (this.count > 0) {
+ if (config.isDebug()) {
+ this.$el.text(_.str.sprintf( _t("Loading (%d)"), this.count));
+ } else {
+ this.$el.text(_t("Loading"));
+ }
+ this.$el.show();
+ this.getParent().$el.addClass('oe_wait');
+ } else {
+ this.count = 0;
+ clearTimeout(this.long_running_timer);
+ // Don't unblock if blocked by somebody else
+ if (self.blocked_ui) {
+ this.blocked_ui = false;
+ framework.unblockUI();
+ }
+ this.$el.fadeOut();
+ this.getParent().$el.removeClass('oe_wait');
+ }
+ }
+});
+
+return Loading;
+});
+
diff --git a/addons/web/static/src/js/chrome/menu.js b/addons/web/static/src/js/chrome/menu.js
new file mode 100644
index 00000000..28585aa1
--- /dev/null
+++ b/addons/web/static/src/js/chrome/menu.js
@@ -0,0 +1,243 @@
+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 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({
+ template: 'Menu',
+ menusTemplate: 'Menu.sections',
+ events: {
+ 'mouseover .o_menu_sections > li:not(.show)': '_onMouseOverMenu',
+ 'click .o_menu_brand': '_onAppNameClicked',
+ },
+
+ init: function (parent, menu_data) {
+ var self = this;
+ 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');
+ });
+
+ // Bus event
+ core.bus.on('change_menu_section', this, this.change_menu_section);
+ },
+ start: function () {
+ var self = this;
+
+ this.$menu_apps = this.$('.o_menu_apps');
+ this.$menu_brand_placeholder = this.$('.o_menu_brand');
+ this.$section_placeholder = this.$('.o_menu_sections');
+ this._updateMenuBrand();
+
+ // 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);
+ var appsMenuProm = this._appsMenu.appendTo(this.$menu_apps);
+
+ // Systray Menu
+ this.systray_menu = new SystrayMenu(this);
+ var systrayMenuProm = this.systray_menu.attachTo(this.$('.o_menu_systray')).then(function() {
+ self.systray_menu.on_attach_callback(); // At this point, we know we are in the DOM
+ dom.initAutoMoreMenu(self.$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 Promise.all([this._super.apply(this, arguments), appsMenuProm, systrayMenuProm]);
+ },
+ change_menu_section: function (primary_menu_id) {
+ if (!this.$menu_sections[primary_menu_id]) {
+ this._updateMenuBrand();
+ return; // unknown menu_id
+ }
+
+ if (this.current_primary_menu === primary_menu_id) {
+ 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 === primary_menu_id) {
+ this._updateMenuBrand(this.menu_data.children[i].name);
+ break;
+ }
+ }
+
+ this.$menu_sections[primary_menu_id].appendTo(this.$section_placeholder);
+ this.current_primary_menu = primary_menu_id;
+
+ core.bus.trigger('resize');
+ },
+ _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,
+ });
+ },
+ /**
+ * Updates the name of the app in the menu to the value of brandName.
+ * If brandName is falsy, hides the menu and its sections.
+ *
+ * @private
+ * @param {brandName} string
+ */
+ _updateMenuBrand: function (brandName) {
+ if (brandName) {
+ this.$menu_brand_placeholder.text(brandName).show();
+ this.$section_placeholder.show();
+ } else {
+ this.$menu_brand_placeholder.hide()
+ this.$section_placeholder.hide();
+ }
+ },
+ _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;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns the id of the current primary (first level) menu.
+ *
+ * @returns {integer}
+ */
+ getCurrentPrimaryMenu: function () {
+ return this.current_primary_menu;
+ },
+ /**
+ * Open the first app, returns whether an application was found.
+ *
+ * @returns {Boolean}
+ */
+ openFirstApp: function () {
+ return 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 {MouseEvent} ev
+ */
+ _onMouseOverMenu: function (ev) {
+ if (config.device.isMobile) {
+ return;
+ }
+ var $target = $(ev.currentTarget);
+ var $opened = $target.siblings('.show');
+ if ($opened.length) {
+ $opened.find('[data-toggle="dropdown"]:first').dropdown('toggle');
+ $opened.removeClass('show');
+ $target.find('[data-toggle="dropdown"]:first').dropdown('toggle');
+ $target.addClass('show');
+ }
+ },
+});
+
+return Menu;
+
+});
diff --git a/addons/web/static/src/js/chrome/root_widget.js b/addons/web/static/src/js/chrome/root_widget.js
new file mode 100644
index 00000000..9cf0515b
--- /dev/null
+++ b/addons/web/static/src/js/chrome/root_widget.js
@@ -0,0 +1,7 @@
+odoo.define('root.widget', function (require) {
+"use strict";
+
+var webClient = require('web.web_client');
+
+return webClient;
+});
diff --git a/addons/web/static/src/js/chrome/systray_menu.js b/addons/web/static/src/js/chrome/systray_menu.js
new file mode 100644
index 00000000..0e25f256
--- /dev/null
+++ b/addons/web/static/src/js/chrome/systray_menu.js
@@ -0,0 +1,65 @@
+odoo.define('web.SystrayMenu', function (require) {
+"use strict";
+
+var dom = require('web.dom');
+var Widget = require('web.Widget');
+
+/**
+ * The SystrayMenu is the class that manage the list of icons in the top right
+ * of the menu bar.
+ */
+var SystrayMenu = Widget.extend({
+ /**
+ * This widget renders the systray menu. It creates and renders widgets
+ * pushed in instance.web.SystrayItems.
+ */
+ init: function (parent) {
+ this._super(parent);
+ this.items = [];
+ this.widgets = [];
+ },
+ /**
+ * Instanciate the items and add them into a temporary fragmenet
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ var proms = [];
+ SystrayMenu.Items = _.sortBy(SystrayMenu.Items, function (item) {
+ return !_.isUndefined(item.prototype.sequence) ? item.prototype.sequence : 50;
+ });
+
+ SystrayMenu.Items.forEach(function (WidgetClass) {
+ var cur_systray_item = new WidgetClass(self);
+ self.widgets.push(cur_systray_item);
+ proms.push(cur_systray_item.appendTo($('<div>')));
+ });
+
+ return this._super.apply(this, arguments).then(function () {
+ return Promise.all(proms);
+ });
+ },
+ on_attach_callback() {
+ this.widgets
+ .filter(widget => widget.on_attach_callback)
+ .forEach(widget => widget.on_attach_callback());
+ },
+ /**
+ * Add the instanciated items, using the object located in this.wisgets
+ */
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self.widgets.forEach(function (widget) {
+ dom.prepend(self.$el, widget.$el);
+ });
+ });
+ },
+});
+
+SystrayMenu.Items = [];
+
+return SystrayMenu;
+
+});
+
diff --git a/addons/web/static/src/js/chrome/user_menu.js b/addons/web/static/src/js/chrome/user_menu.js
new file mode 100644
index 00000000..4281bfd4
--- /dev/null
+++ b/addons/web/static/src/js/chrome/user_menu.js
@@ -0,0 +1,132 @@
+odoo.define('web.UserMenu', function (require) {
+"use strict";
+
+/**
+ * This widget is appended by the webclient to the right of the navbar.
+ * It displays the avatar and the name of the logged user (and optionally the
+ * db name, in debug mode).
+ * If clicked, it opens a dropdown allowing the user to perform actions like
+ * editing its preferences, accessing the documentation, logging out...
+ */
+
+var config = require('web.config');
+var core = require('web.core');
+var framework = require('web.framework');
+var Dialog = require('web.Dialog');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var UserMenu = Widget.extend({
+ template: 'UserMenu',
+
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ start: function () {
+ var self = this;
+ var session = this.getSession();
+ this.$el.on('click', '[data-menu]', function (ev) {
+ ev.preventDefault();
+ var menu = $(this).data('menu');
+ self['_onMenu' + menu.charAt(0).toUpperCase() + menu.slice(1)]();
+ });
+ return this._super.apply(this, arguments).then(function () {
+ var $avatar = self.$('.oe_topbar_avatar');
+ if (!session.uid) {
+ $avatar.attr('src', $avatar.data('default-src'));
+ return Promise.resolve();
+ }
+ var topbar_name = session.name;
+ if (config.isDebug()) {
+ topbar_name = _.str.sprintf("%s (%s)", topbar_name, session.db);
+ }
+ self.$('.oe_topbar_name').text(topbar_name);
+ var avatar_src = session.url('/web/image', {
+ model:'res.users',
+ field: 'image_128',
+ id: session.uid,
+ });
+ $avatar.attr('src', avatar_src);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onMenuAccount: function () {
+ var self = this;
+ this.trigger_up('clear_uncommitted_changes', {
+ callback: function () {
+ self._rpc({route: '/web/session/account'})
+ .then(function (url) {
+ framework.redirect(url);
+ })
+ .guardedCatch(function (result, ev){
+ ev.preventDefault();
+ framework.redirect('https://accounts.odoo.com/account');
+ });
+ },
+ });
+ },
+ /**
+ * @private
+ */
+ _onMenuDocumentation: function () {
+ window.open('https://www.odoo.com/documentation/14.0', '_blank');
+ },
+ /**
+ * @private
+ */
+ _onMenuLogout: function () {
+ this.trigger_up('clear_uncommitted_changes', {
+ callback: this.do_action.bind(this, 'logout'),
+ });
+ },
+ /**
+ * @private
+ */
+ _onMenuSettings: function () {
+ var self = this;
+ var session = this.getSession();
+ this.trigger_up('clear_uncommitted_changes', {
+ callback: function () {
+ self._rpc({
+ model: "res.users",
+ method: "action_get"
+ })
+ .then(function (result) {
+ result.res_id = session.uid;
+ self.do_action(result);
+ });
+ },
+ });
+ },
+ /**
+ * @private
+ */
+ _onMenuSupport: function () {
+ window.open('https://www.odoo.com/buy', '_blank');
+ },
+ /**
+ * @private
+ */
+ _onMenuShortcuts: function() {
+ new Dialog(this, {
+ size: 'large',
+ dialogClass: 'o_act_window',
+ title: _t("Keyboard Shortcuts"),
+ $content: $(QWeb.render("UserMenu.shortcuts"))
+ }).open();
+ },
+});
+
+return UserMenu;
+
+});
diff --git a/addons/web/static/src/js/chrome/web_client.js b/addons/web/static/src/js/chrome/web_client.js
new file mode 100644
index 00000000..3522a7fc
--- /dev/null
+++ b/addons/web/static/src/js/chrome/web_client.js
@@ -0,0 +1,238 @@
+odoo.define('web.WebClient', function (require) {
+"use strict";
+
+var AbstractWebClient = require('web.AbstractWebClient');
+var config = require('web.config');
+var core = require('web.core');
+var data_manager = require('web.data_manager');
+var dom = require('web.dom');
+var Menu = require('web.Menu');
+var session = require('web.session');
+
+return AbstractWebClient.extend({
+ custom_events: _.extend({}, AbstractWebClient.prototype.custom_events, {
+ app_clicked: 'on_app_clicked',
+ menu_clicked: 'on_menu_clicked',
+ }),
+ 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);
+ },
+ bind_events: function () {
+ var self = this;
+ 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 (odoo.loadMenusPromise || odoo.reloadMenus())
+ .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;
+ }
+ }
+ }
+ }
+ odoo.loadMenusPromise = null;
+ return menuData;
+ });
+ },
+ async show_application() {
+ this.set_title();
+
+ await this.menu_dp.add(this.instanciate_menu_widgets());
+ $(window).bind('hashchange', this.on_hashchange);
+
+ const state = $.bbq.getState(true);
+ if (!_.isEqual(_.keys(state), ["cids"])) {
+ return this.on_hashchange();
+ }
+
+ const [data] = await this.menu_dp.add(this._rpc({
+ model: 'res.users',
+ method: 'read',
+ args: [session.uid, ["action_id"]],
+ }));
+ if (data.action_id) {
+ await this.do_action(data.action_id[0]);
+ this.menu.change_menu_section(this.menu.action_id_to_primary_menu_id(data.action_id[0]));
+ return;
+ }
+
+ if (!this.menu.openFirstApp()) {
+ this.trigger_up('webclient_started');
+ }
+ },
+
+ instanciate_menu_widgets: function () {
+ var self = this;
+ var proms = [];
+ 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);
+ proms.push(self.menu.prependTo(self.$el));
+ return Promise.all(proms);
+ });
+ },
+
+ // --------------------------------------------------------------
+ // URL state handling
+ // --------------------------------------------------------------
+ on_hashchange: function (event) {
+ if (this._ignore_hashchange) {
+ this._ignore_hashchange = false;
+ return Promise.resolve();
+ }
+
+ 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.menu_dp.add(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);
+ core.bus.trigger('change_menu_section', menu_id);
+ }
+ }
+ });
+ } else if (state.menu_id) {
+ var action_id = self.menu.menu_id_to_action_id(state.menu_id);
+ return self.menu_dp.add(self.do_action(action_id, {clear_breadcrumbs: true})).then(function () {
+ core.bus.trigger('change_menu_section', state.menu_id);
+ });
+ } else {
+ self.menu.openFirstApp();
+ }
+ }
+ self._current_state = stringstate;
+ }, function () {
+ if (event) {
+ self._ignore_hashchange = true;
+ window.location = event.originalEvent.oldURL;
+ }
+ });
+ },
+
+ // --------------------------------------------------------------
+ // Menu handling
+ // --------------------------------------------------------------
+ on_app_clicked: function (ev) {
+ var self = this;
+ return this.menu_dp.add(data_manager.load_action(ev.data.action_id))
+ .then(function (result) {
+ return self.action_mutex.exec(function () {
+ var completed = new Promise(function (resolve, reject) {
+ var options = _.extend({}, ev.data.options, {
+ clear_breadcrumbs: true,
+ action_menu_id: ev.data.menu_id,
+ });
+
+ Promise.resolve(self._openMenu(result, options))
+ .then(function() {
+ self._on_app_clicked_done(ev)
+ .then(resolve)
+ .guardedCatch(reject);
+ }).guardedCatch(function() {
+ resolve();
+ });
+ setTimeout(function () {
+ resolve();
+ }, 2000);
+ });
+ return completed;
+ });
+ });
+ },
+ _on_app_clicked_done: function (ev) {
+ core.bus.trigger('change_menu_section', ev.data.menu_id);
+ return Promise.resolve();
+ },
+ on_menu_clicked: function (ev) {
+ var self = this;
+ return this.menu_dp.add(data_manager.load_action(ev.data.action_id))
+ .then(function (result) {
+ self.$el.removeClass('o_mobile_menu_opened');
+
+ return self.action_mutex.exec(function () {
+ var completed = new Promise(function (resolve, reject) {
+ Promise.resolve(self._openMenu(result, {
+ clear_breadcrumbs: true,
+ })).then(resolve).guardedCatch(reject);
+
+ setTimeout(function () {
+ resolve();
+ }, 2000);
+ });
+ return completed;
+ });
+ }).guardedCatch(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 {Promise}
+ */
+ _openMenu: function (action, options) {
+ return this.do_action(action, options);
+ },
+});
+
+});