summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/chrome/abstract_web_client.js
diff options
context:
space:
mode:
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.js556
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;
+
+});