diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/chrome | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/chrome')
| -rw-r--r-- | addons/web/static/src/js/chrome/abstract_action.js | 192 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/abstract_web_client.js | 556 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/action_manager.js | 939 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/action_manager_act_window.js | 732 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/action_manager_report.js | 203 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/action_mixin.js | 235 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/apps_menu.js | 102 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/keyboard_navigation_mixin.js | 261 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/loading.js | 80 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/menu.js | 243 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/root_widget.js | 7 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/systray_menu.js | 65 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/user_menu.js | 132 | ||||
| -rw-r--r-- | addons/web/static/src/js/chrome/web_client.js | 238 |
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); + }, +}); + +}); |
