diff options
Diffstat (limited to 'addons/web/static/src/js/chrome/abstract_web_client.js')
| -rw-r--r-- | addons/web/static/src/js/chrome/abstract_web_client.js | 556 |
1 files changed, 556 insertions, 0 deletions
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; + +}); |
