summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/views
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views')
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js607
-rw-r--r--addons/web/static/src/js/views/abstract_model.js286
-rw-r--r--addons/web/static/src/js/views/abstract_renderer.js217
-rw-r--r--addons/web/static/src/js/views/abstract_renderer_owl.js72
-rw-r--r--addons/web/static/src/js/views/abstract_view.js440
-rw-r--r--addons/web/static/src/js/views/action_model.js236
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js883
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js5190
-rw-r--r--addons/web/static/src/js/views/basic/basic_renderer.js926
-rw-r--r--addons/web/static/src/js/views/basic/basic_view.js454
-rw-r--r--addons/web/static/src/js/views/basic/widget_registry.js27
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_controller.js477
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_model.js777
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_popover.js220
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_quick_create.js114
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_renderer.js1006
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_view.js204
-rw-r--r--addons/web/static/src/js/views/field_manager_mixin.js166
-rw-r--r--addons/web/static/src/js/views/file_upload_mixin.js234
-rw-r--r--addons/web/static/src/js/views/file_upload_progress_bar.js76
-rw-r--r--addons/web/static/src/js/views/file_upload_progress_card.js52
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js691
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js1211
-rw-r--r--addons/web/static/src/js/views/form/form_view.js201
-rw-r--r--addons/web/static/src/js/views/graph/graph_controller.js356
-rw-r--r--addons/web/static/src/js/views/graph/graph_model.js322
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js1099
-rw-r--r--addons/web/static/src/js/views/graph/graph_view.js162
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column.js411
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column_progressbar.js288
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column_quick_create.js246
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js537
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_examples_registry.js27
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_model.js445
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_record.js761
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_record_quick_create.js315
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js684
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_view.js119
-rw-r--r--addons/web/static/src/js/views/kanban/quick_create_form_view.js123
-rw-r--r--addons/web/static/src/js/views/list/list_confirm_dialog.js104
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js992
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js1851
-rw-r--r--addons/web/static/src/js/views/list/list_model.js175
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js1470
-rw-r--r--addons/web/static/src/js/views/list/list_view.js137
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_controller.js325
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_model.js1569
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_renderer.js202
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_view.js158
-rw-r--r--addons/web/static/src/js/views/qweb/qweb_view.js208
-rw-r--r--addons/web/static/src/js/views/renderer_wrapper.js15
-rw-r--r--addons/web/static/src/js/views/sample_server.js692
-rw-r--r--addons/web/static/src/js/views/search_panel.js214
-rw-r--r--addons/web/static/src/js/views/search_panel_model_extension.js789
-rw-r--r--addons/web/static/src/js/views/select_create_controllers_registry.js60
-rw-r--r--addons/web/static/src/js/views/signature_dialog.js121
-rw-r--r--addons/web/static/src/js/views/standalone_field_manager_mixin.js64
-rw-r--r--addons/web/static/src/js/views/view_dialogs.js484
-rw-r--r--addons/web/static/src/js/views/view_registry.js44
-rw-r--r--addons/web/static/src/js/views/view_utils.js92
60 files changed, 30428 insertions, 0 deletions
diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js
new file mode 100644
index 00000000..ed5aef40
--- /dev/null
+++ b/addons/web/static/src/js/views/abstract_controller.js
@@ -0,0 +1,607 @@
+odoo.define('web.AbstractController', function (require) {
+"use strict";
+
+/**
+ * The Controller class is the class coordinating the model and the renderer.
+ * It is the C in MVC, and is what was formerly known in Odoo as a View.
+ *
+ * Its role is to listen to events bubbling up from the model/renderer, and call
+ * the appropriate methods if necessary. It also render control panel buttons,
+ * and react to changes in the search view. Basically, all interactions from
+ * the renderer/model with the outside world (meaning server/reading in session/
+ * reading localstorage, ...) has to go through the controller.
+ */
+
+var ActionMixin = require('web.ActionMixin');
+var ajax = require('web.ajax');
+var concurrency = require('web.concurrency');
+const { ComponentWrapper } = require('web.OwlCompatibility');
+var mvc = require('web.mvc');
+var session = require('web.session');
+
+
+var AbstractController = mvc.Controller.extend(ActionMixin, {
+ custom_events: _.extend({}, ActionMixin.custom_events, {
+ navigation_move: '_onNavigationMove',
+ open_record: '_onOpenRecord',
+ switch_view: '_onSwitchView',
+ }),
+ events: {
+ 'click a[type="action"]': '_onActionClicked',
+ },
+
+ /**
+ * @param {Object} param
+ * @param {Object[]} params.actionViews
+ * @param {string} params.activeActions
+ * @param {string} params.bannerRoute
+ * @param {Object} [params.controlPanel]
+ * @param {string} params.controllerID an id to ease the communication with
+ * upstream components
+ * @param {string} params.displayName
+ * @param {Object} params.initialState
+ * @param {string} params.modelName
+ * @param {ActionModel} [params.searchModel]
+ * @param {string} [params.searchPanel]
+ * @param {string} params.viewType
+ * @param {boolean} [params.withControlPanel]
+ * @param {boolean} [params.withSearchPanel]
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this._title = params.displayName;
+ this.modelName = params.modelName;
+ this.activeActions = params.activeActions;
+ this.controllerID = params.controllerID;
+ this.initialState = params.initialState;
+ this.bannerRoute = params.bannerRoute;
+ this.actionViews = params.actionViews;
+ this.viewType = params.viewType;
+ // use a DropPrevious to correctly handle concurrent updates
+ this.dp = new concurrency.DropPrevious();
+
+ this.withControlPanel = params.withControlPanel;
+ this.withSearchPanel = params.withSearchPanel;
+ if (params.searchModel) {
+ this.searchModel = params.searchModel;
+ }
+ if (this.withControlPanel) {
+ const { Component, props } = params.controlPanel;
+ this.ControlPanel = Component;
+ this.controlPanelProps = props;
+ }
+ if (this.withSearchPanel) {
+ const { Component, props } = params.searchPanel;
+ this.SearchPanel = Component;
+ this.searchPanelProps = props;
+ }
+ },
+
+ /**
+ * Simply renders and updates the url.
+ *
+ * @returns {Promise}
+ */
+ start: async function () {
+ this.$el.addClass('o_view_controller');
+ this.renderButtons();
+ const promises = [this._super(...arguments)];
+ if (this.withControlPanel) {
+ this._updateControlPanelProps(this.initialState);
+ this._controlPanelWrapper = new ComponentWrapper(this, this.ControlPanel, this.controlPanelProps);
+ this._controlPanelWrapper.env.bus.on('focus-view', this, () => this._giveFocus());
+ promises.push(this._controlPanelWrapper.mount(this.el, { position: 'first-child' }));
+ }
+ if (this.withSearchPanel) {
+ this._searchPanelWrapper = new ComponentWrapper(this, this.SearchPanel, this.searchPanelProps);
+ const content = this.el.querySelector(':scope .o_content');
+ content.classList.add('o_controller_with_searchpanel');
+ promises.push(this._searchPanelWrapper.mount(content, { position: 'first-child' }));
+ }
+ await Promise.all(promises);
+ await this._update(this.initialState, { shouldUpdateSearchComponents: false });
+ this.updateButtons();
+ this.el.classList.toggle('o_view_sample_data', this.model.isInSampleMode());
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this.$buttons) {
+ this.$buttons.off();
+ }
+ ActionMixin.destroy.call(this);
+ this._super.apply(this, arguments);
+ },
+ /**
+ * Called each time the controller is attached into the DOM.
+ */
+ on_attach_callback: function () {
+ ActionMixin.on_attach_callback.call(this);
+ this.searchModel.on('search', this, this._onSearch);
+ this.searchModel.trigger('focus-control-panel');
+ if (this.withControlPanel) {
+ this.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
+ }
+ if (!(this.renderer instanceof owl.Component)) {
+ this.renderer.on_attach_callback();
+ }
+ },
+ /**
+ * Called each time the controller is detached from the DOM.
+ */
+ on_detach_callback: function () {
+ ActionMixin.on_detach_callback.call(this);
+ this.searchModel.off('search', this);
+ if (this.withControlPanel) {
+ this.searchModel.off('get-controller-query-params', this);
+ }
+ if (!(this.renderer instanceof owl.Component)) {
+ this.renderer.on_detach_callback();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ canBeRemoved: function () {
+ // AAB: get rid of 'readonlyIfRealDiscard' option when on_hashchange mechanism is improved
+ return this.discardChanges(undefined, {
+ noAbandon: true,
+ readonlyIfRealDiscard: true,
+ });
+ },
+ /**
+ * Discards the changes made on the record associated to the given ID, or
+ * all changes made by the current controller if no recordID is given. For
+ * example, when the user opens the 'home' screen, the action manager calls
+ * this method on the active view to make sure it is ok to open the home
+ * screen (and lose all current state).
+ *
+ * Note that it returns a Promise, because the view could choose to ask the
+ * user if he agrees to discard.
+ *
+ * @param {string} [recordID]
+ * if not given, we consider all the changes made by the controller
+ * @param {Object} [options]
+ * @returns {Promise} resolved if properly discarded, rejected otherwise
+ */
+ discardChanges: function (recordID, options) {
+ return Promise.resolve();
+ },
+ /**
+ * Export the state of the controller containing information that is shared
+ * between different controllers of a same action (like the current search
+ * model state or the states of some components).
+ *
+ * @returns {Object}
+ */
+ exportState() {
+ const exported = {
+ searchModel: this.searchModel.exportState(),
+ };
+ if (this.withSearchPanel) {
+ const searchPanel = this._searchPanelWrapper.componentRef.comp;
+ exported.searchPanel = searchPanel.exportState();
+ }
+ return exported;
+ },
+ /**
+ * Parses and imports a previously exported state.
+ *
+ * @param {Object} state
+ */
+ importState(state) {
+ this.searchModel.importState(state.searchModel);
+ if (this.withSearchPanel) {
+ const searchPanel = this._searchPanelWrapper.componentRef.comp;
+ searchPanel.importState(state.searchPanel);
+ }
+ },
+ /**
+ * The use of this method is discouraged. It is still snakecased, because
+ * it currently is used in many templates, but we will move to a simpler
+ * mechanism as soon as we can.
+ *
+ * @deprecated
+ * @param {string} action type of action, such as 'create', 'read', ...
+ * @returns {boolean}
+ */
+ is_action_enabled: function (action) {
+ return this.activeActions[action];
+ },
+ /**
+ * Short helper method to reload the view
+ *
+ * @param {Object} [params={}]
+ * @param {Object} [params.controllerState={}]
+ * @returns {Promise}
+ */
+ reload: async function (params = {}) {
+ if (params.controllerState) {
+ this.importState(params.controllerState);
+ Object.assign(params, this.searchModel.get('query'));
+ }
+ return this.update(params, {});
+ },
+ /**
+ * This is the main entry point for the controller. Changes from the search
+ * view arrive in this method, and internal changes can sometimes also call
+ * this method. It is basically the way everything notifies the controller
+ * that something has changed.
+ *
+ * The update method is responsible for fetching necessary data, then
+ * updating the renderer and wait for the rendering to complete.
+ *
+ * @param {Object} params will be given to the model and to the renderer
+ * @param {Object} [options={}]
+ * @param {boolean} [options.reload=true] if true, the model will reload data
+ * @returns {Promise}
+ */
+ async update(params, options = {}) {
+ const shouldReload = 'reload' in options ? options.reload : true;
+ if (shouldReload) {
+ this.handle = await this.dp.add(this.model.reload(this.handle, params));
+ }
+ const localState = this.renderer.getLocalState();
+ const state = this.model.get(this.handle, { withSampleData: true });
+ const promises = [
+ this._updateRendererState(state, params).then(() => {
+ this.renderer.setLocalState(localState);
+ }),
+ this._update(this.model.get(this.handle), params)
+ ];
+ await this.dp.add(Promise.all(promises));
+ this.updateButtons();
+ this.el.classList.toggle('o_view_sample_data', this.model.isInSampleMode());
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+
+ /**
+ * Meant to be overriden to return a proper object.
+ * @private
+ * @param {Object} [state]
+ * @return {(Object|null)}
+ */
+ _getPagingInfo: function (state) {
+ return null;
+ },
+ /**
+ * Meant to be overriden to return a proper object.
+ * @private
+ * @param {Object} [state]
+ * @return {(Object|null)}
+ */
+ _getActionMenuItems: function (state) {
+ return null;
+ },
+ /**
+ * Gives the focus to the renderer if not in sample mode.
+ *
+ * @private
+ */
+ _giveFocus() {
+ if (!this.model.isInSampleMode()) {
+ this.renderer.giveFocus();
+ }
+ },
+ /**
+ * This method is the way a view can notifies the outside world that
+ * something has changed. The main use for this is to update the url, for
+ * example with a new id.
+ *
+ * @private
+ */
+ _pushState: function () {
+ this.trigger_up('push_state', {
+ controllerID: this.controllerID,
+ state: this.getState(),
+ });
+ },
+ /**
+ * @private
+ * @param {function} callback function to execute before removing classname
+ * 'o_view_sample_data' (may be async). This allows to reload and/or
+ * rerender before removing the className, thus preventing the view from
+ * flickering.
+ */
+ async _removeSampleData(callback) {
+ this.model.leaveSampleMode();
+ if (callback) {
+ await callback();
+ }
+ this.el.classList.remove('o_view_sample_data');
+ },
+ /**
+ * Renders the html provided by the route specified by the
+ * bannerRoute attribute on the controller (banner_route in the template).
+ * Renders it before the view output and add a css class 'o_has_banner' to it.
+ * There can be only one banner displayed at a time.
+ *
+ * If the banner contains stylesheet links or js files, they are moved to <head>
+ * (and will only be fetched once).
+ *
+ * Route example:
+ * @http.route('/module/hello', auth='user', type='json')
+ * def hello(self):
+ * return {'html': '<h1>hello, world</h1>'}
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _renderBanner: async function () {
+ if (this.bannerRoute !== undefined) {
+ const response = await this._rpc({
+ route: this.bannerRoute,
+ params: {context: session.user_context},
+ });
+ if (!response.html) {
+ this.$el.removeClass('o_has_banner');
+ return Promise.resolve();
+ }
+ this.$el.addClass('o_has_banner');
+ var $banner = $(response.html);
+ // we should only display one banner at a time
+ if (this._$banner && this._$banner.remove) {
+ this._$banner.remove();
+ }
+ // Css and js are moved to <head>
+ var defs = [];
+ $('link[rel="stylesheet"]', $banner).each(function (i, link) {
+ defs.push(ajax.loadCSS(link.href));
+ link.remove();
+ });
+ $('script[type="text/javascript"]', $banner).each(function (i, js) {
+ defs.push(ajax.loadJS(js.src));
+ js.remove();
+ });
+ await Promise.all(defs);
+ $banner.insertBefore(this.$('> .o_content'));
+ this._$banner = $banner;
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _startRenderer: function () {
+ if (this.renderer instanceof owl.Component) {
+ return this.renderer.mount(this.$('.o_content')[0]);
+ }
+ return this.renderer.appendTo(this.$('.o_content'));
+ },
+ /**
+ * This method is called after each update or when the start method is
+ * completed.
+ *
+ * Its primary use is to be used as a hook to update all parts of the UI,
+ * besides the renderer. For example, it may be used to enable/disable
+ * some buttons in the control panel, such as the current graph type for a
+ * graph view.
+ *
+ * FIXME: this hook should be synchronous, and called once async rendering
+ * has been done.
+ *
+ * @private
+ * @param {Object} state the state given by the model
+ * @param {Object} [params={}]
+ * @param {Array} [params.breadcrumbs]
+ * @param {Object} [params.shouldUpdateSearchComponents]
+ * @returns {Promise}
+ */
+ async _update(state, params) {
+ // AAB: update the control panel -> this will be moved elsewhere at some point
+ if (!this.$buttons) {
+ this.renderButtons();
+ }
+ const promises = [this._renderBanner()];
+ if (params.shouldUpdateSearchComponents !== false) {
+ if (this.withControlPanel) {
+ this._updateControlPanelProps(state);
+ if (params.breadcrumbs) {
+ this.controlPanelProps.breadcrumbs = params.breadcrumbs;
+ }
+ promises.push(this.updateControlPanel());
+ }
+ if (this.withSearchPanel) {
+ this._updateSearchPanel();
+ }
+ }
+ this._pushState();
+ await Promise.all(promises);
+ },
+ /**
+ * Can be used to update the key 'cp_content'. This method is called in start and _update methods.
+ *
+ * @private
+ * @param {Object} state the state given by the model
+ */
+ _updateControlPanelProps(state) {
+ if (!this.controlPanelProps.cp_content) {
+ this.controlPanelProps.cp_content = {};
+ }
+ if (this.$buttons) {
+ this.controlPanelProps.cp_content.$buttons = this.$buttons;
+ }
+ Object.assign(this.controlPanelProps, {
+ actionMenus: this._getActionMenuItems(state),
+ pager: this._getPagingInfo(state),
+ title: this.getTitle(),
+ });
+ },
+ /**
+ * @private
+ * @param {Object} state
+ * @param {Object} newProps
+ * @returns {Promise}
+ */
+ _updatePaging: async function (state, newProps) {
+ const pagingInfo = this._getPagingInfo(state);
+ if (pagingInfo) {
+ Object.assign(pagingInfo, newProps);
+ return this.updateControlPanel({ pager: pagingInfo });
+ }
+ },
+ /**
+ * Updates the state of the renderer (handle both Widget and Component
+ * renderers).
+ *
+ * @private
+ * @param {Object} state the model state
+ * @param {Object} [params={}] will be given to the model and to the renderer
+ * @return {Promise}
+ */
+ _updateRendererState(state, params = {}) {
+ if (this.renderer instanceof owl.Component) {
+ return this.renderer.update(state);
+ }
+ return this.renderer.updateState(state, params);
+ },
+ /**
+ * @private
+ * @param {Object} [newProps={}]
+ * @return {Promise}
+ */
+ async _updateSearchPanel(newProps) {
+ Object.assign(this.searchPanelProps, newProps);
+ await this._searchPanelWrapper.update(this.searchPanelProps);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * When a user clicks on an <a> link with type="action", we need to actually
+ * do the action. This kind of links is used a lot in no-content helpers.
+ *
+ * * if the link has both data-model and data-method attributes, the
+ * corresponding method is called, chained to any action it would
+ * return. An optional data-reload-on-close (set to a non-falsy value)
+ * also causes th underlying view to be reloaded after the dialog is
+ * closed.
+ * * if the link has a name attribute, invoke the action with that
+ * identifier (see :class:`ActionManager.doAction` to not get the
+ * details)
+ * * otherwise an *action descriptor* is built from the link's data-
+ * attributes (model, res-id, views, domain and context)
+ *
+ * @private
+ * @param ev
+ */
+ _onActionClicked: function (ev) { // FIXME: maybe this should also work on <button> tags?
+ ev.preventDefault();
+ var $target = $(ev.currentTarget);
+ var self = this;
+ var data = $target.data();
+
+ if (data.method !== undefined && data.model !== undefined) {
+ var options = {};
+ if (data.reloadOnClose) {
+ options.on_close = function () {
+ self.trigger_up('reload');
+ };
+ }
+ this.dp.add(this._rpc({
+ model: data.model,
+ method: data.method,
+ context: session.user_context,
+ })).then(function (action) {
+ if (action !== undefined) {
+ self.do_action(action, options);
+ }
+ });
+ } else if ($target.attr('name')) {
+ this.do_action(
+ $target.attr('name'),
+ data.context && {additional_context: data.context}
+ );
+ } else {
+ this.do_action({
+ name: $target.attr('title') || _.str.strip($target.text()),
+ type: 'ir.actions.act_window',
+ res_model: data.model || this.modelName,
+ res_id: data.resId,
+ target: 'current', // TODO: make customisable?
+ views: data.views || (data.resId ? [[false, 'form']] : [[false, 'list'], [false, 'form']]),
+ domain: data.domain || [],
+ }, {
+ additional_context: _.extend({}, data.context)
+ });
+ }
+ },
+ /**
+ * Called either from the control panel to focus the controller
+ * or from the view to focus the search bar
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNavigationMove: function (ev) {
+ switch (ev.data.direction) {
+ case 'up':
+ ev.stopPropagation();
+ this.searchModel.trigger('focus-control-panel');
+ break;
+ case 'down':
+ ev.stopPropagation();
+ this._giveFocus();
+ break;
+ }
+ },
+ /**
+ * When an Odoo event arrives requesting a record to be opened, this method
+ * gets the res_id, and request a switch view in the appropriate mode
+ *
+ * Note: this method seems wrong, it relies on the model being a basic model,
+ * to get the res_id. It should receive the res_id in the event data
+ * @todo move this to basic controller?
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {number} ev.data.id The local model ID for the record to be
+ * opened
+ * @param {string} [ev.data.mode='readonly']
+ */
+ _onOpenRecord: function (ev) {
+ ev.stopPropagation();
+ var record = this.model.get(ev.data.id, {raw: true});
+ this.trigger_up('switch_view', {
+ view_type: 'form',
+ res_id: record.res_id,
+ mode: ev.data.mode || 'readonly',
+ model: this.modelName,
+ });
+ },
+ /**
+ * Called when there is a change in the search view, so the current action's
+ * environment needs to be updated with the new domain, context, groupby,...
+ *
+ * @private
+ * @param {Object} searchQuery
+ */
+ _onSearch: function (searchQuery) {
+ this.reload(_.extend({ offset: 0, groupsOffset: 0 }, searchQuery));
+ },
+ /**
+ * Intercepts the 'switch_view' event to add the controllerID into the data,
+ * and lets the event bubble up.
+ *
+ * @param {OdooEvent} ev
+ */
+ _onSwitchView: function (ev) {
+ ev.data.controllerID = this.controllerID;
+ },
+});
+
+return AbstractController;
+
+});
diff --git a/addons/web/static/src/js/views/abstract_model.js b/addons/web/static/src/js/views/abstract_model.js
new file mode 100644
index 00000000..db2cce99
--- /dev/null
+++ b/addons/web/static/src/js/views/abstract_model.js
@@ -0,0 +1,286 @@
+odoo.define('web.AbstractModel', function (require) {
+"use strict";
+
+/**
+ * An AbstractModel is the M in MVC. We tend to think of MVC more on the server
+ * side, but we are talking here on the web client side.
+ *
+ * The duties of the Model are to fetch all relevant data, and to make them
+ * available for the rest of the view. Also, every modification to that data
+ * should pass through the model.
+ *
+ * Note that the model is not a widget, it does not need to be rendered or
+ * appended to the dom. However, it inherits from the EventDispatcherMixin,
+ * in order to be able to notify its parent by bubbling events up.
+ *
+ * The model is able to generate sample (fake) data when there is no actual data
+ * in database. This feature can be activated by instantiating the model with
+ * param "useSampleModel" set to true. In this case, the model instantiates a
+ * duplicated version of itself, parametrized to call a SampleServer (JS)
+ * instead of doing RPCs. Here is how it works: the main model first load the
+ * data normally (from database), and then checks whether the result is empty or
+ * not. If it is, it asks the sample model to load with the exact same params,
+ * and it thus enters in "sample" mode. The model keeps doing this at reload,
+ * but only if the (re)load params haven't changed: as soon as a param changes,
+ * the "sample" mode is left, and it never enters it again in the future (in the
+ * lifetime of the model instance). To access those sample data from the outside,
+ * 'get' must be called with the the option "withSampleData" set to true. In
+ * this case, if the main model is in "sample" mode, it redirects the call to the
+ * sample model.
+ */
+
+var fieldUtils = require('web.field_utils');
+var mvc = require('web.mvc');
+const SampleServer = require('web.SampleServer');
+
+
+var AbstractModel = mvc.Model.extend({
+ /**
+ * @param {Widget} parent
+ * @param {Object} [params={}]
+ * @param {Object} [params.fields]
+ * @param {string} [params.modelName]
+ * @param {boolean} [params.isSampleModel=false] if true, will fetch data
+ * from a SampleServer instead of doing RPCs
+ * @param {boolean} [params.useSampleModel=false] if true, will use a sample
+ * model to generate sample data when there is no "real" data in database
+ * @param {AbstractModel} [params.SampleModel] the AbstractModel class
+ * to instantiate as sample model. This model won't do any rpc, but will
+ * rather call a SampleServer that will generate sample data. This param
+ * must be set when params.useSampleModel is true.
+ */
+ init(parent, params = {}) {
+ this._super(...arguments);
+ this.useSampleModel = params.useSampleModel || false;
+ if (params.isSampleModel) {
+ this.isSampleModel = true;
+ this.sampleServer = new SampleServer(params.modelName, params.fields);
+ } else if (this.useSampleModel) {
+ const sampleModelParams = Object.assign({}, params, {
+ isSampleModel: true,
+ SampleModel: null,
+ useSampleModel: false,
+ });
+ this.sampleModel = new params.SampleModel(this, sampleModelParams);
+ this._isInSampleMode = false;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to call get on the sampleModel when we are in sample mode, and
+ * option 'withSampleData' is set to true.
+ *
+ * @override
+ * @param {any} _
+ * @param {Object} [options]
+ * @param {boolean} [options.withSampleData=false]
+ */
+ get(_, options) {
+ let state;
+ if (options && options.withSampleData && this._isInSampleMode) {
+ state = this.sampleModel.__get(...arguments);
+ } else {
+ state = this.__get(...arguments);
+ }
+ return state;
+ },
+ /**
+ * Under some conditions, the model is designed to generate sample data if
+ * there is no real data in database. This function returns a boolean which
+ * indicates the mode of the model: if true, we are in "sample" mode.
+ *
+ * @returns {boolean}
+ */
+ isInSampleMode() {
+ return !!this._isInSampleMode;
+ },
+ /**
+ * Disables the sample data (forever) on this model instance.
+ */
+ leaveSampleMode() {
+ if (this.useSampleModel) {
+ this.useSampleModel = false;
+ this._isInSampleMode = false;
+ this.sampleModel.destroy();
+ }
+ },
+ /**
+ * Override to check if we need to call the sample model (and if so, to do
+ * it) after loading the data, in the case where there is no real data to
+ * display.
+ *
+ * @override
+ */
+ async load(params) {
+ this.loadParams = params;
+ const handle = await this.__load(...arguments);
+ await this._callSampleModel('__load', handle, ...arguments);
+ return handle;
+ },
+ /**
+ * When something changes, the data may need to be refetched. This is the
+ * job for this method: reloading (only if necessary) all the data and
+ * making sure that they are ready to be redisplayed.
+ * Sometimes, we reload the data with the "same" params as the initial load
+ * params (see '_haveParamsChanged'). When we do, if we were in "sample" mode,
+ * we call again the sample server after the reload if there is still no data
+ * to display. When the parameters change, we automatically leave "sample"
+ * mode.
+ *
+ * @param {any} _
+ * @param {Object} [params]
+ * @returns {Promise}
+ */
+ async reload(_, params) {
+ const handle = await this.__reload(...arguments);
+ if (this._isInSampleMode) {
+ if (!this._haveParamsChanged(params)) {
+ await this._callSampleModel('__reload', handle, ...arguments);
+ } else {
+ this.leaveSampleMode();
+ }
+ }
+ return handle;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} method
+ * @param {any} handle
+ * @param {...any} args
+ * @returns {Promise}
+ */
+ async _callSampleModel(method, handle, ...args) {
+ if (this.useSampleModel && this._isEmpty(handle)) {
+ try {
+ if (method === '__load') {
+ await this.sampleModel.__load(...args);
+ } else if (method === '__reload') {
+ await this.sampleModel.__reload(...args);
+ }
+ this._isInSampleMode = true;
+ } catch (error) {
+ if (error instanceof SampleServer.UnimplementedRouteError) {
+ this.leaveSampleMode();
+ } else {
+ throw error;
+ }
+ }
+ } else {
+ this.leaveSampleMode();
+ }
+ },
+ /**
+ * @private
+ * @returns {Object}
+ */
+ __get() {
+ return {};
+ },
+ /**
+ * This function can be overriden to determine if the result of a load or
+ * a reload is empty. In the affirmative, we will try to generate sample
+ * data to prevent from having an empty state to display.
+ *
+ * @private
+ * @params {any} handle, the value returned by a load or a reload
+ * @returns {boolean}
+ */
+ _isEmpty(/* handle */) {
+ return false;
+ },
+ /**
+ * To override to do the initial load of the data (this function is supposed
+ * to be called only once).
+ *
+ * @private
+ * @returns {Promise}
+ */
+ async __load() {
+ return Promise.resolve();
+ },
+ /**
+ * Processes date(time) and selection field values sent by the server.
+ * Converts data(time) values to moment instances.
+ * Converts false values of selection fields to 0 if 0 is a valid key,
+ * because the server doesn't make a distinction between false and 0, and
+ * always sends false when value is 0.
+ *
+ * @param {Object} field the field description
+ * @param {*} value
+ * @returns {*} the processed value
+ */
+ _parseServerValue: function (field, value) {
+ if (field.type === 'date' || field.type === 'datetime') {
+ // process date(time): convert into a moment instance
+ value = fieldUtils.parse[field.type](value, field, {isUTC: true});
+ } else if (field.type === 'selection' && value === false) {
+ // process selection: convert false to 0, if 0 is a valid key
+ var hasKey0 = _.find(field.selection, function (option) {
+ return option[0] === 0;
+ });
+ value = hasKey0 ? 0 : value;
+ }
+ return value;
+ },
+ /**
+ * To override to reload data (this function may be called several times,
+ * after the initial load has been done).
+ *
+ * @private
+ * @returns {Promise}
+ */
+ async __reload() {
+ return Promise.resolve();
+ },
+ /**
+ * Determines whether or not the given params (reload params) differ from
+ * the initial ones (this.loadParams). This is used to leave "sample" mode
+ * as soon as a parameter (e.g. domain) changes.
+ *
+ * @private
+ * @param {Object} [params={}]
+ * @param {Object} [params.context]
+ * @param {Array[]} [params.domain]
+ * @param {Object} [params.timeRanges]
+ * @param {string[]} [params.groupBy]
+ * @returns {boolean}
+ */
+ _haveParamsChanged(params = {}) {
+ for (const key of ['context', 'domain', 'timeRanges']) {
+ if (key in params) {
+ const diff = JSON.stringify(params[key]) !== JSON.stringify(this.loadParams[key]);
+ if (diff) {
+ return true;
+ }
+ }
+ }
+ if (this.useSampleModel && 'groupBy' in params) {
+ return JSON.stringify(params.groupBy) !== JSON.stringify(this.loadParams.groupedBy);
+ }
+ },
+ /**
+ * Override to redirect all rpcs to the SampleServer if this.isSampleModel
+ * is true.
+ *
+ * @override
+ */
+ async _rpc() {
+ if (this.isSampleModel) {
+ return this.sampleServer.mockRpc(...arguments);
+ }
+ return this._super(...arguments);
+ },
+});
+
+return AbstractModel;
+
+});
diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js
new file mode 100644
index 00000000..c2cc7f2f
--- /dev/null
+++ b/addons/web/static/src/js/views/abstract_renderer.js
@@ -0,0 +1,217 @@
+odoo.define('web.AbstractRenderer', function (require) {
+"use strict";
+
+/**
+ * The renderer should not handle pagination, data loading, or coordination
+ * with the control panel. It is only concerned with rendering.
+ *
+ */
+
+var mvc = require('web.mvc');
+
+// Renderers may display sample data when there is no real data to display. In
+// this case the data is displayed with opacity and can't be clicked. Moreover,
+// we also want to prevent the user from accessing DOM elements with TAB
+// navigation. This is the list of elements we won't allow to focus.
+const FOCUSABLE_ELEMENTS = [
+ // focusable by default
+ 'a', 'button', 'input', 'select', 'textarea',
+ // manually set
+ '[tabindex="0"]'
+].map((sel) => `:scope ${sel}`).join(', ');
+
+/**
+ * @class AbstractRenderer
+ */
+return mvc.Renderer.extend({
+ // Defines the elements suppressed when in demo data. This must be a list
+ // of DOM selectors matching view elements that will:
+ // 1. receive the 'o_sample_data_disabled' class (greyd out & no user events)
+ // 2. have themselves and any of their focusable children removed from the
+ // tab navigation
+ sampleDataTargets: [],
+
+ /**
+ * @override
+ * @param {string} [params.noContentHelp]
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this.arch = params.arch;
+ this.noContentHelp = params.noContentHelp;
+ this.withSearchPanel = params.withSearchPanel;
+ },
+ /**
+ * The rendering is asynchronous. The start
+ * method simply makes sure that we render the view.
+ *
+ * @returns {Promise}
+ */
+ async start() {
+ this.$el.addClass(this.arch.attrs.class);
+ if (this.withSearchPanel) {
+ this.$el.addClass('o_renderer_with_searchpanel');
+ }
+ await Promise.all([this._render(), this._super()]);
+ },
+ /**
+ * Called each time the renderer is attached into the DOM.
+ */
+ on_attach_callback: function () {},
+ /**
+ * Called each time the renderer is detached from the DOM.
+ */
+ on_detach_callback: function () {},
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns any relevant state that the renderer might want to keep.
+ *
+ * The idea is that a renderer can be destroyed, then be replaced by another
+ * one instantiated with the state from the model and the localState from
+ * the renderer, and the end result should be the same.
+ *
+ * The kind of state that we expect the renderer to have is mostly DOM state
+ * such as the scroll position, the currently active tab page, ...
+ *
+ * This method is called before each updateState, by the controller.
+ *
+ * @see setLocalState
+ * @returns {any}
+ */
+ getLocalState: function () {
+ },
+ /**
+ * Order to focus to be given to the content of the current view
+ */
+ giveFocus: function () {
+ },
+ /**
+ * Resets state that renderer keeps, state may contains scroll position,
+ * the currently active tab page, ...
+ *
+ * @see getLocalState
+ * @see setLocalState
+ */
+ resetLocalState() {
+ },
+ /**
+ * This is the reverse operation from getLocalState. With this method, we
+ * expect the renderer to restore all DOM state, if it is relevant.
+ *
+ * This method is called after each updateState, by the controller.
+ *
+ * @see getLocalState
+ * @param {any} localState the result of a call to getLocalState
+ */
+ setLocalState: function (localState) {
+ },
+ /**
+ * Updates the state of the view. It retriggers a full rerender, unless told
+ * otherwise (for optimization for example).
+ *
+ * @param {any} state
+ * @param {Object} params
+ * @param {boolean} [params.noRender=false]
+ * if true, the method only updates the state without rerendering
+ * @returns {Promise}
+ */
+ async updateState(state, params) {
+ this._setState(state);
+ if (!params.noRender) {
+ await this._render();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Renders the widget. This method can be overriden to perform actions
+ * before or after the view has been rendered.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ async _render() {
+ await this._renderView();
+ this._suppressFocusableElements();
+ },
+ /**
+ * @private
+ * @param {Object} context
+ */
+ _renderNoContentHelper: function (context) {
+ let templateName;
+ if (!context && this.noContentHelp) {
+ templateName = "web.ActionHelper";
+ context = { noContentHelp: this.noContentHelp };
+ } else {
+ templateName = "web.NoContentHelper";
+ }
+ const template = document.createElement('template');
+ // FIXME: retrieve owl qweb instance via the env set on Component s.t.
+ // it also works in the tests (importing 'web.env' wouldn't). This
+ // won't be necessary as soon as this will be written in owl.
+ const owlQWeb = owl.Component.env.qweb;
+ template.innerHTML = owlQWeb.renderToString(templateName, context);
+ this.el.append(template.content.firstChild);
+ },
+ /**
+ * Actual rendering. This method is meant to be overridden by concrete
+ * renderers.
+ *
+ * @abstract
+ * @private
+ * @returns {Promise}
+ */
+ async _renderView() { },
+ /**
+ * Assigns a new state to the renderer if not false.
+ *
+ * @private
+ * @param {any} [state=false]
+ */
+ _setState(state = false) {
+ if (state !== false) {
+ this.state = state;
+ }
+ },
+ /**
+ * Suppresses 'tabindex' property on any focusable element located inside
+ * root elements defined in the `this.sampleDataTargets` object and assigns
+ * the 'o_sample_data_disabled' class to these root elements.
+ *
+ * @private
+ * @see sampleDataTargets
+ */
+ _suppressFocusableElements() {
+ if (!this.state.isSample || this.isEmbedded) {
+ return;
+ }
+ const rootEls = [];
+ for (const selector of this.sampleDataTargets) {
+ rootEls.push(...this.el.querySelectorAll(`:scope ${selector}`));
+ }
+ const focusableEls = new Set(rootEls);
+ for (const rootEl of rootEls) {
+ rootEl.classList.add('o_sample_data_disabled');
+ for (const focusableEl of rootEl.querySelectorAll(FOCUSABLE_ELEMENTS)) {
+ focusableEls.add(focusableEl);
+ }
+ }
+ for (const focusableEl of focusableEls) {
+ focusableEl.setAttribute('tabindex', -1);
+ if (focusableEl.classList.contains('dropdown-item')) {
+ // Tells Bootstrap to ignore the dropdown item in keynav
+ focusableEl.classList.add('disabled');
+ }
+ }
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/abstract_renderer_owl.js b/addons/web/static/src/js/views/abstract_renderer_owl.js
new file mode 100644
index 00000000..0c97159e
--- /dev/null
+++ b/addons/web/static/src/js/views/abstract_renderer_owl.js
@@ -0,0 +1,72 @@
+odoo.define('web.AbstractRendererOwl', function () {
+ "use strict";
+
+ // Renderers may display sample data when there is no real data to display. In
+ // this case the data is displayed with opacity and can't be clicked. Moreover,
+ // we also want to prevent the user from accessing DOM elements with TAB
+ // navigation. This is the list of elements we won't allow to focus.
+ const FOCUSABLE_ELEMENTS = [
+ // focusable by default
+ 'a', 'button', 'input', 'select', 'textarea',
+ // manually set
+ '[tabindex="0"]'
+ ].map((sel) => `:scope ${sel}`).join(', ');
+
+ class AbstractRenderer extends owl.Component {
+
+ constructor() {
+ super(...arguments);
+ // Defines the elements suppressed when in demo data. This must be a list
+ // of DOM selectors matching view elements that will:
+ // 1. receive the 'o_sample_data_disabled' class (greyd out & no user events)
+ // 2. have themselves and any of their focusable children removed from the
+ // tab navigation
+ this.sampleDataTargets = [];
+ }
+
+ mounted() {
+ this._suppressFocusableElements();
+ }
+
+ patched() {
+ this._suppressFocusableElements();
+ }
+
+ /**
+ * Suppresses 'tabindex' property on any focusable element located inside
+ * root elements defined in the `this.sampleDataTargets` object and assigns
+ * the 'o_sample_data_disabled' class to these root elements.
+ *
+ * @private
+ * @see sampleDataTargets
+ */
+ _suppressFocusableElements() {
+ if (!this.props.isSample || this.props.isEmbedded) {
+ const disabledEls = this.el.querySelectorAll(`.o_sample_data_disabled`);
+ disabledEls.forEach(el => el.classList.remove('o_sample_data_disabled'));
+ return;
+ }
+ const rootEls = [];
+ for (const selector of this.sampleDataTargets) {
+ rootEls.push(...this.el.querySelectorAll(`:scope ${selector}`));
+ }
+ const focusableEls = new Set(rootEls);
+ for (const rootEl of rootEls) {
+ rootEl.classList.add('o_sample_data_disabled');
+ for (const focusableEl of rootEl.querySelectorAll(FOCUSABLE_ELEMENTS)) {
+ focusableEls.add(focusableEl);
+ }
+ }
+ for (const focusableEl of focusableEls) {
+ focusableEl.setAttribute('tabindex', -1);
+ if (focusableEl.classList.contains('dropdown-item')) {
+ // Tells Bootstrap to ignore the dropdown item in keynav
+ focusableEl.classList.add('disabled');
+ }
+ }
+ }
+ }
+
+ return AbstractRenderer;
+
+});
diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js
new file mode 100644
index 00000000..9440bf67
--- /dev/null
+++ b/addons/web/static/src/js/views/abstract_view.js
@@ -0,0 +1,440 @@
+odoo.define('web.AbstractView', function (require) {
+"use strict";
+
+/**
+ * This is the base class inherited by all (JS) views. Odoo JS views are the
+ * widgets used to display information in the main area of the web client
+ * (note: the search view is not a "JS view" in that sense).
+ *
+ * The abstract view role is to take a set of fields, an arch (the xml
+ * describing the view in db), and some params, and then, to create a
+ * controller, a renderer and a model. This is the classical MVC pattern, but
+ * the word 'view' has historical significance in Odoo code, so we replaced the
+ * V in MVC by the 'renderer' word.
+ *
+ * JS views are supposed to be used like this:
+ * 1. instantiate a view with some arch, fields and params
+ * 2. call the getController method on the view instance. This returns a
+ * controller (with a model and a renderer as sub widgets)
+ * 3. append the controller somewhere
+ *
+ * Note that once a controller has been instantiated, the view class is no
+ * longer useful (unless you want to create another controller), and will be
+ * in most case discarded.
+ */
+
+const ActionModel = require("web/static/src/js/views/action_model.js");
+var AbstractModel = require('web.AbstractModel');
+var AbstractRenderer = require('web.AbstractRenderer');
+var AbstractController = require('web.AbstractController');
+const ControlPanel = require('web.ControlPanel');
+const SearchPanel = require("web/static/src/js/views/search_panel.js");
+var mvc = require('web.mvc');
+var viewUtils = require('web.viewUtils');
+
+const { Component } = owl;
+
+var Factory = mvc.Factory;
+
+var AbstractView = Factory.extend({
+ // name displayed in view switchers
+ display_name: '',
+ // indicates whether or not the view is mobile-friendly
+ mobile_friendly: false,
+ // icon is the font-awesome icon to display in the view switcher
+ icon: 'fa-question',
+ // multi_record is used to distinguish views displaying a single record
+ // (e.g. FormView) from those that display several records (e.g. ListView)
+ multi_record: true,
+ // viewType is the type of the view, like 'form', 'kanban', 'list'...
+ viewType: undefined,
+ // determines if a search bar is available
+ withSearchBar: true,
+ // determines the search menus available and their orders
+ searchMenuTypes: ['filter', 'groupBy', 'favorite'],
+ // determines if a control panel should be instantiated
+ withControlPanel: true,
+ // determines if a search panel could be instantiated
+ withSearchPanel: true,
+ // determines the MVC components to use
+ config: _.extend({}, Factory.prototype.config, {
+ Model: AbstractModel,
+ Renderer: AbstractRenderer,
+ Controller: AbstractController,
+ ControlPanel,
+ SearchPanel,
+ }),
+
+ /**
+ * The constructor function is supposed to set 3 variables: rendererParams,
+ * controllerParams and loadParams. These values will be used to initialize
+ * the model, renderer and controllers.
+ *
+ * @constructs AbstractView
+ *
+ * @param {Object} viewInfo
+ * @param {Object|string} viewInfo.arch
+ * @param {Object} viewInfo.fields
+ * @param {Object} viewInfo.fieldsInfo
+ * @param {Object} params
+ * @param {string} [params.modelName]
+ * @param {Object} [params.action={}]
+ * @param {Object} [params.context={}]
+ * @param {string} [params.controllerID]
+ * @param {number} [params.count]
+ * @param {number} [params.currentId]
+ * @param {Object} [params.controllerState]
+ * @param {string} [params.displayName]
+ * @param {Array[]} [params.domain=[]]
+ * @param {Object[]} [params.dynamicFilters] transmitted to the
+ * ControlPanel
+ * @param {number[]} [params.ids]
+ * @param {boolean} [params.isEmbedded=false]
+ * @param {Object} [params.searchQuery={}]
+ * @param {Object} [params.searchQuery.context={}]
+ * @param {Array[]} [params.searchQuery.domain=[]]
+ * @param {string[]} [params.searchQuery.groupBy=[]]
+ * @param {Object} [params.userContext={}]
+ * @param {boolean} [params.useSampleModel]
+ * @param {boolean} [params.withControlPanel=AbstractView.prototype.withControlPanel]
+ * @param {boolean} [params.withSearchPanel=AbstractView.prototype.withSearchPanel]
+ */
+ init: function (viewInfo, params) {
+ this._super.apply(this, arguments);
+
+ var action = params.action || {};
+ params = _.defaults(params, this._extractParamsFromAction(action));
+
+ // in general, the fieldsView has to be processed by the View (e.g. the
+ // arch is a string that needs to be parsed) ; the only exception is for
+ // inline form views inside form views, as they are processed alongside
+ // the main view, but they are opened in a FormViewDialog which
+ // instantiates another FormView (unlike kanban or list subviews for
+ // which only a Renderer is instantiated)
+ if (typeof viewInfo.arch === 'string') {
+ this.fieldsView = this._processFieldsView(viewInfo);
+ } else {
+ this.fieldsView = viewInfo;
+ }
+ this.arch = this.fieldsView.arch;
+ this.fields = this.fieldsView.viewFields;
+ this.userContext = params.userContext || {};
+
+ // the boolean parameter 'isEmbedded' determines if the view should be
+ // considered as a subview. For now this is only used by the graph
+ // controller that appends a 'Group By' button beside the 'Measures'
+ // button when the graph view is embedded.
+ var isEmbedded = params.isEmbedded || false;
+
+ // The noContentHelper's message can be empty, i.e. either a real empty string
+ // or an empty html tag. In both cases, we consider the helper empty.
+ var help = params.noContentHelp || "";
+ var htmlHelp = document.createElement("div");
+ htmlHelp.innerHTML = help;
+ this.rendererParams = {
+ arch: this.arch,
+ isEmbedded: isEmbedded,
+ noContentHelp: htmlHelp.innerText.trim() ? help : "",
+ };
+
+ this.controllerParams = {
+ actionViews: params.actionViews,
+ activeActions: {
+ edit: this.arch.attrs.edit ? !!JSON.parse(this.arch.attrs.edit) : true,
+ create: this.arch.attrs.create ? !!JSON.parse(this.arch.attrs.create) : true,
+ delete: this.arch.attrs.delete ? !!JSON.parse(this.arch.attrs.delete) : true,
+ duplicate: this.arch.attrs.duplicate ? !!JSON.parse(this.arch.attrs.duplicate) : true,
+ },
+ bannerRoute: this.arch.attrs.banner_route,
+ controllerID: params.controllerID,
+ displayName: params.displayName,
+ isEmbedded: isEmbedded,
+ modelName: params.modelName,
+ viewType: this.viewType,
+ };
+
+ var controllerState = params.controllerState || {};
+ var currentId = controllerState.currentId || params.currentId;
+ this.loadParams = {
+ context: params.context,
+ count: params.count || ((this.controllerParams.ids !== undefined) &&
+ this.controllerParams.ids.length) || 0,
+ domain: params.domain,
+ modelName: params.modelName,
+ res_id: currentId,
+ res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined),
+ };
+
+ const useSampleModel = 'useSampleModel' in params ?
+ params.useSampleModel :
+ !!(this.arch.attrs.sample && JSON.parse(this.arch.attrs.sample));
+
+ this.modelParams = {
+ fields: this.fields,
+ modelName: params.modelName,
+ useSampleModel,
+ };
+ if (useSampleModel) {
+ this.modelParams.SampleModel = this.config.Model;
+ }
+
+ var defaultOrder = this.arch.attrs.default_order;
+ if (defaultOrder) {
+ this.loadParams.orderedBy = _.map(defaultOrder.split(','), function (order) {
+ order = order.trim().split(' ');
+ return {name: order[0], asc: order[1] !== 'desc'};
+ });
+ }
+ if (params.searchQuery) {
+ this._updateMVCParams(params.searchQuery);
+ }
+
+ this.withControlPanel = this.withControlPanel && params.withControlPanel;
+ this.withSearchPanel = this.withSearchPanel &&
+ this.multi_record && params.withSearchPanel &&
+ !('search_panel' in params.context && !params.search_panel);
+
+ const searchModelParams = Object.assign({}, params, { action });
+ if (this.withControlPanel || this.withSearchPanel) {
+ const { arch, fields, favoriteFilters } = params.controlPanelFieldsView || {};
+ const archInfo = ActionModel.extractArchInfo({ search: arch }, this.viewType);
+ const controlPanelInfo = archInfo[this.config.ControlPanel.modelExtension];
+ const searchPanelInfo = archInfo[this.config.SearchPanel.modelExtension];
+ this.withSearchPanel = this.withSearchPanel && Boolean(searchPanelInfo);
+ Object.assign(searchModelParams, {
+ fields,
+ favoriteFilters,
+ controlPanelInfo,
+ searchPanelInfo,
+ });
+ }
+ const searchModel = this._createSearchModel(searchModelParams);
+ this.controllerParams.searchModel = searchModel;
+ if (this.controllerParams.controlPanel) {
+ this.controllerParams.controlPanel.props.searchModel = searchModel;
+ }
+ if (this.controllerParams.searchPanel) {
+ this.controllerParams.searchPanel.props.searchModel = searchModel;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {Object} params
+ * @param {Object} extraExtensions
+ * @returns {ActionModel}
+ */
+ _createSearchModel: function (params, extraExtensions) {
+ // Search model + common config
+ const { fields, favoriteFilters, controlPanelInfo, searchPanelInfo } = params;
+ const extensions = Object.assign({}, extraExtensions);
+ const importedState = params.controllerState || {};
+
+ // Control panel params
+ if (this.withControlPanel) {
+ // Control panel (Model)
+ const ControlPanelComponent = this.config.ControlPanel;
+ extensions[ControlPanelComponent.modelExtension] = {
+ actionId: params.action.id,
+ // control initialization
+ activateDefaultFavorite: params.activateDefaultFavorite,
+ archNodes: controlPanelInfo.children,
+ dynamicFilters: params.dynamicFilters,
+ favoriteFilters,
+ withSearchBar: params.withSearchBar,
+ };
+ this.controllerParams.withControlPanel = true;
+ // Control panel (Component)
+ const controlPanelProps = {
+ action: params.action,
+ breadcrumbs: params.breadcrumbs,
+ fields,
+ searchMenuTypes: params.searchMenuTypes,
+ view: this.fieldsView,
+ views: params.action.views && params.action.views.filter(
+ v => v.multiRecord === this.multi_record
+ ),
+ withBreadcrumbs: params.withBreadcrumbs,
+ withSearchBar: params.withSearchBar,
+ };
+ this.controllerParams.controlPanel = {
+ Component: ControlPanelComponent,
+ props: controlPanelProps,
+ };
+ }
+
+ // Search panel params
+ if (this.withSearchPanel) {
+ // Search panel (Model)
+ const SearchPanelComponent = this.config.SearchPanel;
+ extensions[SearchPanelComponent.modelExtension] = {
+ archNodes: searchPanelInfo.children,
+ };
+ this.controllerParams.withSearchPanel = true;
+ this.rendererParams.withSearchPanel = true;
+ // Search panel (Component)
+ const searchPanelProps = {
+ importedState: importedState.searchPanel,
+ };
+ if (searchPanelInfo.attrs.class) {
+ searchPanelProps.className = searchPanelInfo.attrs.class;
+ }
+ this.controllerParams.searchPanel = {
+ Component: SearchPanelComponent,
+ props: searchPanelProps,
+ };
+ }
+
+ const searchModel = new ActionModel(extensions, {
+ env: Component.env,
+ modelName: params.modelName,
+ context: Object.assign({}, this.loadParams.context),
+ domain: this.loadParams.domain || [],
+ importedState: importedState.searchModel,
+ searchMenuTypes: params.searchMenuTypes,
+ searchQuery: params.searchQuery,
+ fields,
+ });
+
+ return searchModel;
+ },
+
+ /**
+ * @override
+ */
+ getController: async function () {
+ const _super = this._super.bind(this);
+ const { searchModel } = this.controllerParams;
+ await searchModel.load();
+ this._updateMVCParams(searchModel.get("query"));
+ // get the parent of the model if it already exists, as _super will
+ // set the new controller as parent, which we don't want
+ const modelParent = this.model && this.model.getParent();
+ const [controller] = await Promise.all([
+ _super(...arguments),
+ searchModel.isReady(),
+ ]);
+ if (modelParent) {
+ // if we already add a model, restore its parent
+ this.model.setParent(modelParent);
+ }
+ return controller;
+ },
+ /**
+ * Ensures that only one instance of AbstractModel is created
+ *
+ * @override
+ */
+ getModel: function () {
+ if (!this.model) {
+ this.model = this._super.apply(this, arguments);
+ }
+ return this.model;
+ },
+ /**
+ * This is useful to customize the actual class to use before calling
+ * createView.
+ *
+ * @param {Controller} Controller
+ */
+ setController: function (Controller) {
+ this.Controller = Controller;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} [action]
+ * @param {Object} [action.context || {}]
+ * @param {boolean} [action.context.no_breadcrumbs=false]
+ * @param {integer} [action.context.active_id]
+ * @param {integer[]} [action.context.active_ids]
+ * @param {Object} [action.controlPanelFieldsView]
+ * @param {string} [action.display_name]
+ * @param {Array[]} [action.domain=[]]
+ * @param {string} [action.help]
+ * @param {integer} [action.id]
+ * @param {integer} [action.limit]
+ * @param {string} [action.name]
+ * @param {string} [action.res_model]
+ * @param {string} [action.target]
+ * @param {boolean} [action.useSampleModel]
+ * @returns {Object}
+ */
+ _extractParamsFromAction: function (action) {
+ action = action || {};
+ var context = action.context || {};
+ var inline = action.target === 'inline';
+ const params = {
+ actionId: action.id || false,
+ actionViews: action.views || [],
+ activateDefaultFavorite: !context.active_id && !context.active_ids,
+ context: action.context || {},
+ controlPanelFieldsView: action.controlPanelFieldsView,
+ currentId: action.res_id ? action.res_id : undefined, // load returns 0
+ displayName: action.display_name || action.name,
+ domain: action.domain || [],
+ limit: action.limit,
+ modelName: action.res_model,
+ noContentHelp: action.help,
+ searchMenuTypes: inline ? [] : this.searchMenuTypes,
+ withBreadcrumbs: 'no_breadcrumbs' in context ? !context.no_breadcrumbs : true,
+ withControlPanel: this.withControlPanel,
+ withSearchBar: inline ? false : this.withSearchBar,
+ withSearchPanel: this.withSearchPanel,
+ };
+ if ('useSampleModel' in action) {
+ params.useSampleModel = action.useSampleModel;
+ }
+ return params;
+ },
+ /**
+ * Processes a fieldsView. In particular, parses its arch.
+ *
+ * @private
+ * @param {Object} fieldsView
+ * @param {string} fieldsView.arch
+ * @returns {Object} the processed fieldsView
+ */
+ _processFieldsView: function (fieldsView) {
+ var fv = _.extend({}, fieldsView);
+ fv.arch = viewUtils.parseArch(fv.arch);
+ fv.viewFields = _.defaults({}, fv.viewFields, fv.fields);
+ return fv;
+ },
+ /**
+ * Hook to update the renderer, controller and load params with the result
+ * of a search (i.e. a context, a domain and a groupBy).
+ *
+ * @private
+ * @param {Object} searchQuery
+ * @param {Object} searchQuery.context
+ * @param {Object} [searchQuery.timeRanges]
+ * @param {Array[]} searchQuery.domain
+ * @param {string[]} searchQuery.groupBy
+ */
+ _updateMVCParams: function (searchQuery) {
+ this.loadParams = _.extend(this.loadParams, {
+ context: searchQuery.context,
+ domain: searchQuery.domain,
+ groupedBy: searchQuery.groupBy,
+ });
+ this.loadParams.orderedBy = Array.isArray(searchQuery.orderedBy) && searchQuery.orderedBy.length ?
+ searchQuery.orderedBy :
+ this.loadParams.orderedBy;
+ if (searchQuery.timeRanges) {
+ this.loadParams.timeRanges = searchQuery.timeRanges;
+ this.rendererParams.timeRanges = searchQuery.timeRanges;
+ }
+ },
+});
+
+return AbstractView;
+
+});
diff --git a/addons/web/static/src/js/views/action_model.js b/addons/web/static/src/js/views/action_model.js
new file mode 100644
index 00000000..c3b69271
--- /dev/null
+++ b/addons/web/static/src/js/views/action_model.js
@@ -0,0 +1,236 @@
+odoo.define("web/static/src/js/views/action_model.js", function (require) {
+ "use strict";
+
+ const Domain = require("web.Domain");
+ const { FACET_ICONS } = require("web.searchUtils");
+ const { Model } = require("web/static/src/js/model.js");
+ const { parseArch } = require("web.viewUtils");
+ const pyUtils = require("web.py_utils");
+ const Registry = require("web.Registry");
+
+ const isNotNull = (value) => value !== null && value !== undefined;
+ const isObject = (obj) => typeof obj === "object" && obj !== null;
+
+ /**
+ * @extends Model.Extension
+ */
+ class ActionModelExtension extends Model.Extension {
+
+ //---------------------------------------------------------------------
+ // Public
+ //---------------------------------------------------------------------
+
+ /**
+ * Initiates the asynchronous tasks of the extension and returns a
+ * promise resolved as soon as all the informations necessary to build
+ * the search query are ready.
+ * @returns {Promise}
+ */
+ async callLoad() {
+ this.loadPromise = super.callLoad(...arguments);
+ await this.loadPromise;
+ }
+
+ /**
+ * Returns a promise resolved when the extension is completely ready.
+ * @returns {Promise}
+ */
+ async isReady() {
+ await this.loadPromise;
+ }
+
+ //---------------------------------------------------------------------
+ // Static
+ //---------------------------------------------------------------------
+
+ /**
+ * @abstract
+ * @param {Object} archs
+ * @param {string | null} [viewType=null]
+ * @returns {null}
+ */
+ static extractArchInfo() {
+ return null;
+ }
+ }
+
+ /**
+ * @extends Model
+ */
+ class ActionModel extends Model {
+
+ //---------------------------------------------------------------------
+ // Public
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ get(property) {
+ switch (property) {
+ case "query": return this.config.searchQuery || this._getQuery();
+ case "facets": return this._getFacets();
+ }
+ return super.get(...arguments);
+ }
+
+ /**
+ * Returns a promise resolved when all extensions are completely ready.
+ * @returns {Promise}
+ */
+ async isReady() {
+ await this._awaitExtensions();
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ async _awaitExtensions() {
+ await Promise.all(this.extensions.flat().map(
+ (extension) => extension.isReady()
+ ));
+ }
+
+ /**
+ * @override
+ */
+ __get(excluded, property) {
+ const results = super.__get(...arguments);
+ switch (property) {
+ case "domain": return [this.config.domain, ...results];
+ case "context": return [this.config.context, ...results];
+ }
+ return results;
+ }
+
+ /**
+ * Validates and formats all facets given by the extensions. This is
+ * done here rather than in the search bar because the searchMenuTypes
+ * are available only to the model.
+ * @private
+ * @returns {Object[]}
+ */
+ _getFacets() {
+ const types = this.config.searchMenuTypes || [];
+ const isValidType = (type) => (
+ !['groupBy', 'comparison'].includes(type) || types.includes(type)
+ );
+ const facets = [];
+ for (const extension of this.extensions.flat()) {
+ for (const facet of extension.get("facets") || []) {
+ if (!isValidType(facet.type)) {
+ continue;
+ }
+ facet.separator = facet.type === 'groupBy' ? ">" : this.env._t("or");
+ if (facet.type in FACET_ICONS) {
+ facet.icon = FACET_ICONS[facet.type];
+ }
+ facets.push(facet);
+ }
+ }
+ return facets;
+ }
+
+ /**
+ * @typedef TimeRanges
+ * @property {string} fieldName
+ * @property {string} comparisonRangeId
+ * @property {Array[]} range
+ * @property {string} rangeDescription
+ * @property {Array[]} comparisonRange
+ * @property {string} comparisonRangeDescription
+ */
+ /**
+ * @typedef Query
+ * @property {Object} context
+ * @property {Array[]} domain
+ * @property {string[]} groupBy
+ * @property {string[]} orderedBy
+ * @property {TimeRanges?} timeRanges
+ */
+ /**
+ * @private
+ * @returns {Query}
+ */
+ _getQuery() {
+ const evalContext = this.env.session.user_context;
+ const contexts = this.__get(null, "context");
+ const domains = this.__get(null, "domain");
+ const query = {
+ context: pyUtils.eval("contexts", contexts, evalContext),
+ domain: Domain.prototype.normalizeArray(
+ pyUtils.eval("domains", domains, evalContext)
+ ),
+ orderedBy: this.get("orderedBy") || [],
+ };
+ const searchMenuTypes = this.config.searchMenuTypes || [];
+ if (searchMenuTypes.includes("groupBy")) {
+ query.groupBy = this.get("groupBy") || [];
+ } else {
+ query.groupBy = [];
+ }
+ if (searchMenuTypes.includes("comparison")) {
+ query.timeRanges = this.get("timeRanges") || {};
+ }
+ return query;
+ }
+
+ /**
+ * Overridden to trigger a "search" event as soon as the query data
+ * are ready.
+ * @override
+ */
+ async _loadExtensions({ isInitialLoad }) {
+ await super._loadExtensions(...arguments);
+ if (!isInitialLoad) {
+ this.trigger("search", this.get("query"));
+ await this._awaitExtensions();
+ }
+ }
+
+ //---------------------------------------------------------------------
+ // Static
+ //---------------------------------------------------------------------
+
+ /**
+ * @param {Object} archs
+ * @param {string | null} [viewType=null]
+ * @returns {Object}
+ */
+ static extractArchInfo(archs, viewType = null) {
+ const parsedArchs = {};
+ if (!archs.search) {
+ archs.search = "<search/>";
+ }
+ for (const key in archs) {
+ const { attrs, children } = parseArch(archs[key]);
+ const objectChildren = children.filter(isObject);
+ parsedArchs[key] = {
+ attrs,
+ children: objectChildren,
+ };
+ }
+ const archInfo = {};
+ for (const key of this.registry.keys()) {
+ const extension = this.registry.get(key);
+ const result = extension.extractArchInfo(parsedArchs, viewType);
+ if (isNotNull(result)) {
+ archInfo[key] = result;
+ }
+ }
+ return archInfo;
+ }
+ }
+
+ ActionModel.Extension = ActionModelExtension;
+ ActionModel.registry = new Registry(null,
+ (value) => value.prototype instanceof ActionModel.Extension
+ );
+
+ return ActionModel;
+});
diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js
new file mode 100644
index 00000000..4cbe9027
--- /dev/null
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -0,0 +1,883 @@
+odoo.define('web.BasicController', function (require) {
+"use strict";
+
+/**
+ * The BasicController is mostly here to share code between views that will use
+ * a BasicModel (or a subclass). Currently, the BasicViews are the form, list
+ * and kanban views.
+ */
+
+var AbstractController = require('web.AbstractController');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var FieldManagerMixin = require('web.FieldManagerMixin');
+var TranslationDialog = require('web.TranslationDialog');
+
+var _t = core._t;
+
+var BasicController = AbstractController.extend(FieldManagerMixin, {
+ events: Object.assign({}, AbstractController.prototype.events, {
+ 'click .o_content': '_onContentClicked',
+ }),
+ custom_events: _.extend({}, AbstractController.prototype.custom_events, FieldManagerMixin.custom_events, {
+ discard_changes: '_onDiscardChanges',
+ pager_changed: '_onPagerChanged',
+ reload: '_onReload',
+ resequence_records: '_onResequenceRecords',
+ set_dirty: '_onSetDirty',
+ load_optional_fields: '_onLoadOptionalFields',
+ save_optional_fields: '_onSaveOptionalFields',
+ translate: '_onTranslate',
+ }),
+ /**
+ * @override
+ * @param {Object} params
+ * @param {boolean} params.archiveEnabled
+ * @param {boolean} params.confirmOnDelete
+ * @param {boolean} params.hasButtons
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.archiveEnabled = params.archiveEnabled;
+ this.confirmOnDelete = params.confirmOnDelete;
+ this.hasButtons = params.hasButtons;
+ FieldManagerMixin.init.call(this, this.model);
+ this.mode = params.mode || 'readonly';
+ // savingDef is used to ensure that we always wait for pending save
+ // operations to complete before checking if there are changes to
+ // discard when discardChanges is called
+ this.savingDef = Promise.resolve();
+ // discardingDef is used to ensure that we don't ask twice the user if
+ // he wants to discard changes, when 'canBeDiscarded' is called several
+ // times "in parallel"
+ this.discardingDef = null;
+ this.viewId = params.viewId;
+ },
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ start: async function () {
+ // add classname to reflect the (absence of) access rights (used to
+ // correctly display the nocontent helper)
+ this.$el.toggleClass('o_cannot_create', !this.activeActions.create);
+ await this._super(...arguments);
+ },
+ /**
+ * Called each time the controller is dettached into the DOM
+ */
+ on_detach_callback() {
+ this._super.apply(this, arguments);
+ this.renderer.resetLocalState();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Determines if we can discard the current changes. If the model is not
+ * dirty, that is not a problem. However, if it is dirty, we have to ask
+ * the user for confirmation.
+ *
+ * @override
+ * @param {string} [recordID] - default to main recordID
+ * @returns {Promise<boolean>}
+ * resolved if can be discarded, a boolean value is given to tells
+ * if there is something to discard or not
+ * rejected otherwise
+ */
+ canBeDiscarded: function (recordID) {
+ var self = this;
+ if (this.discardingDef) {
+ // discard dialog is already open
+ return this.discardingDef;
+ }
+ if (!this.isDirty(recordID)) {
+ return Promise.resolve(false);
+ }
+
+ var message = _t("The record has been modified, your changes will be discarded. Do you want to proceed?");
+ this.discardingDef = new Promise(function (resolve, reject) {
+ var dialog = Dialog.confirm(self, message, {
+ title: _t("Warning"),
+ confirm_callback: () => {
+ resolve(true);
+ self.discardingDef = null;
+ },
+ cancel_callback: () => {
+ reject();
+ self.discardingDef = null;
+ },
+ });
+ dialog.on('closed', self.discardingDef, reject);
+ });
+ return this.discardingDef;
+ },
+ /**
+ * Ask the renderer if all associated field widget are in a valid state for
+ * saving (valid value and non-empty value for required fields). If this is
+ * not the case, this notifies the user with a warning containing the names
+ * of the invalid fields.
+ *
+ * Note: changing the style of invalid fields is the renderer's job.
+ *
+ * @param {string} [recordID] - default to main recordID
+ * @return {boolean}
+ */
+ canBeSaved: function (recordID) {
+ var fieldNames = this.renderer.canBeSaved(recordID || this.handle);
+ if (fieldNames.length) {
+ this._notifyInvalidFields(fieldNames);
+ return false;
+ }
+ return true;
+ },
+ /**
+ * Waits for the mutex to be unlocked and for changes to be saved, then
+ * calls _.discardChanges.
+ * This ensures that the confirm dialog isn't displayed directly if there is
+ * a pending 'write' rpc.
+ *
+ * @see _.discardChanges
+ */
+ discardChanges: function (recordID, options) {
+ return Promise.all([this.mutex.getUnlockedDef(), this.savingDef])
+ .then(this._discardChanges.bind(this, recordID || this.handle, options));
+ },
+ /**
+ * Method that will be overridden by the views with the ability to have selected ids
+ *
+ * @returns {Array}
+ */
+ getSelectedIds: function () {
+ return [];
+ },
+ /**
+ * Returns true iff the given recordID (or the main recordID) is dirty.
+ *
+ * @param {string} [recordID] - default to main recordID
+ * @returns {boolean}
+ */
+ isDirty: function (recordID) {
+ return this.model.isDirty(recordID || this.handle);
+ },
+ /**
+ * Saves the record whose ID is given if necessary (@see _saveRecord).
+ *
+ * @param {string} [recordID] - default to main recordID
+ * @param {Object} [options]
+ * @returns {Promise}
+ * Resolved with the list of field names (whose value has been modified)
+ * Rejected if the record can't be saved
+ */
+ saveRecord: function (recordID, options) {
+ var self = this;
+ // Some field widgets can't detect (all) their changes immediately or
+ // may have to validate them before notifying them, so we ask them to
+ // commit their current value before saving. This has to be done outside
+ // of the mutex protection of saving because commitChanges will trigger
+ // changes and these are also protected. However, we must wait for the
+ // mutex to be idle to ensure that onchange RPCs returned before asking
+ // field widgets to commit their value (and validate it, for instance
+ // for one2many with required fields). So the actual saving has to be
+ // done after these changes. Also the commitChanges operation might not
+ // be synchronous for other reason (e.g. the x2m fields will ask the
+ // user if some discarding has to be made). This operation must also be
+ // mutex-protected as commitChanges function of x2m has to be aware of
+ // all final changes made to a row.
+ var unlockedMutex = this.mutex.getUnlockedDef()
+ .then(function () {
+ return self.renderer.commitChanges(recordID || self.handle);
+ })
+ .then(function () {
+ return self.mutex.exec(self._saveRecord.bind(self, recordID, options));
+ });
+ this.savingDef = new Promise(function (resolve) {
+ unlockedMutex.then(resolve).guardedCatch(resolve);
+ });
+
+ return unlockedMutex;
+ },
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ update: async function (params, options) {
+ this.mode = params.mode || this.mode;
+ return this._super(params, options);
+ },
+ /**
+ * @override
+ */
+ reload: function (params) {
+ if (params && params.controllerState) {
+ if (params.controllerState.currentId) {
+ params.currentId = params.controllerState.currentId;
+ }
+ params.ids = params.controllerState.resIds;
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Does the necessary action when trying to "abandon" a given record (e.g.
+ * when trying to make a new record readonly without having saved it). By
+ * default, if the abandoned record is the main view one, the only possible
+ * action is to leave the current view. Otherwise, it is a x2m line, ask the
+ * model to remove it.
+ *
+ * @private
+ * @param {string} [recordID] - default to main recordID
+ */
+ _abandonRecord: function (recordID) {
+ recordID = recordID || this.handle;
+ if (recordID === this.handle) {
+ this.trigger_up('history_back');
+ } else {
+ this.model.removeLine(recordID);
+ }
+ },
+ /**
+ * We override applyChanges (from the field manager mixin) to protect it
+ * with a mutex.
+ *
+ * @override
+ */
+ _applyChanges: function (dataPointID, changes, event) {
+ var _super = FieldManagerMixin._applyChanges.bind(this);
+ return this.mutex.exec(function () {
+ return _super(dataPointID, changes, event);
+ });
+ },
+ /**
+ * Archive the current selection
+ *
+ * @private
+ * @param {number[]} ids
+ * @param {boolean} archive
+ * @returns {Promise}
+ */
+ _archive: async function (ids, archive) {
+ if (ids.length === 0) {
+ return Promise.resolve();
+ }
+ if (archive) {
+ await this.model.actionArchive(ids, this.handle);
+ } else {
+ await this.model.actionUnarchive(ids, this.handle);
+ }
+ return this.update({}, {reload: false});
+ },
+ /**
+ * When the user clicks on a 'action button', this function determines what
+ * should happen.
+ *
+ * @private
+ * @param {Object} attrs the attrs of the button clicked
+ * @param {Object} [record] the current state of the view
+ * @returns {Promise}
+ */
+ _callButtonAction: function (attrs, record) {
+ record = record || this.model.get(this.handle);
+ const actionData = Object.assign({}, attrs, {
+ context: record.getContext({additionalContext: attrs.context || {}})
+ });
+ const recordData = {
+ context: record.getContext(),
+ currentID: record.data.id,
+ model: record.model,
+ resIDs: record.res_ids,
+ };
+ return this._executeButtonAction(actionData, recordData);
+ },
+ /**
+ * Called by the field manager mixin to confirm that a change just occured
+ * (after that potential onchanges have been applied).
+ *
+ * Basically, this only relays the notification to the renderer with the
+ * new state.
+ *
+ * @param {string} id - the id of one of the view's records
+ * @param {string[]} fields - the changed fields
+ * @param {OdooEvent} e - the event that triggered the change
+ * @returns {Promise}
+ */
+ _confirmChange: function (id, fields, e) {
+ if (e.name === 'discard_changes' && e.target.reset) {
+ // the target of the discard event is a field widget. In that
+ // case, we simply want to reset the specific field widget,
+ // not the full view
+ return e.target.reset(this.model.get(e.target.dataPointID), e, true);
+ }
+
+ var state = this.model.get(this.handle);
+ return this.renderer.confirmChange(state, id, fields, e);
+ },
+ /**
+ * Ask the user to confirm he wants to save the record
+ * @private
+ */
+ _confirmSaveNewRecord: function () {
+ var self = this;
+ var def = new Promise(function (resolve, reject) {
+ var message = _t("You need to save this new record before editing the translation. Do you want to proceed?");
+ var dialog = Dialog.confirm(self, message, {
+ title: _t("Warning"),
+ confirm_callback: resolve.bind(self, true),
+ cancel_callback: reject,
+ });
+ dialog.on('closed', self, reject);
+ });
+ return def;
+ },
+ /**
+ * Delete records (and ask for confirmation if necessary)
+ *
+ * @param {string[]} ids list of local record ids
+ */
+ _deleteRecords: function (ids) {
+ var self = this;
+ function doIt() {
+ return self.model
+ .deleteRecords(ids, self.modelName)
+ .then(self._onDeletedRecords.bind(self, ids));
+ }
+ if (this.confirmOnDelete) {
+ const message = ids.length > 1 ?
+ _t("Are you sure you want to delete these records?") :
+ _t("Are you sure you want to delete this record?");
+ Dialog.confirm(this, message, { confirm_callback: doIt });
+ } else {
+ doIt();
+ }
+ },
+ /**
+ * Disables buttons so that they can't be clicked anymore.
+ *
+ * @private
+ */
+ _disableButtons: function () {
+ if (this.$buttons) {
+ this.$buttons.find('button').attr('disabled', true);
+ }
+ },
+ /**
+ * Discards the changes made to the record whose ID is given, if necessary.
+ * Automatically leaves to default mode for the given record.
+ *
+ * @private
+ * @param {string} [recordID] - default to main recordID
+ * @param {Object} [options]
+ * @param {boolean} [options.readonlyIfRealDiscard=false]
+ * After discarding record changes, the usual option is to make the
+ * record readonly. However, the action manager calls this function
+ * at inappropriate times in the current code and in that case, we
+ * don't want to go back to readonly if there is nothing to discard
+ * (e.g. when switching record in edit mode in form view, we expect
+ * the new record to be in edit mode too, but the view manager calls
+ * this function as the URL changes...) @todo get rid of this when
+ * the webclient/action_manager's hashchange mechanism is improved.
+ * @param {boolean} [options.noAbandon=false]
+ * @returns {Promise}
+ */
+ _discardChanges: function (recordID, options) {
+ var self = this;
+ recordID = recordID || this.handle;
+ options = options || {};
+ return this.canBeDiscarded(recordID)
+ .then(function (needDiscard) {
+ if (options.readonlyIfRealDiscard && !needDiscard) {
+ return;
+ }
+ self.model.discardChanges(recordID);
+ if (options.noAbandon) {
+ return;
+ }
+ if (self.model.canBeAbandoned(recordID)) {
+ self._abandonRecord(recordID);
+ return;
+ }
+ return self._confirmSave(recordID);
+ });
+ },
+ /**
+ * Enables buttons so they can be clicked again.
+ *
+ * @private
+ */
+ _enableButtons: function () {
+ if (this.$buttons) {
+ this.$buttons.find('button').removeAttr('disabled');
+ }
+ },
+ /**
+ * Executes the action associated with a button
+ *
+ * @private
+ * @param {Object} actionData: the descriptor of the action
+ * @param {string} actionData.type: the button's action's type, accepts "object" or "action"
+ * @param {string} actionData.name: the button's action's name
+ * either the model method's name for type "object"
+ * or the action's id in database, or xml_id
+ * @param {string} actionData.context: the action's execution context
+ *
+ * @param {Object} recordData: basic information on the current record(s)
+ * @param {number[]} recordData.resIDs: record ids:
+ * - on which an object method applies
+ * - that will be used as active_ids to load an action
+ * @param {string} recordData.model: model name
+ * @param {Object} recordData.context: the records' context, will be used to load
+ * the action, and merged into actionData.context at execution time
+ *
+ * @returns {Promise}
+ */
+ async _executeButtonAction(actionData, recordData) {
+ const prom = new Promise((resolve, reject) => {
+ this.trigger_up('execute_action', {
+ action_data: actionData,
+ env: recordData,
+ on_closed: () => this.isDestroyed() ? Promise.resolve() : this.reload(),
+ on_success: resolve,
+ on_fail: () => this.update({}, { reload: false }).then(reject).guardedCatch(reject)
+ });
+ });
+ return this.alive(prom);
+ },
+ /**
+ * Override to add the current record ID (currentId) and the list of ids
+ * (resIds) in the current dataPoint to the exported state.
+ *
+ * @override
+ */
+ exportState: function () {
+ var state = this._super.apply(this, arguments);
+ var env = this.model.get(this.handle, {env: true});
+ return _.extend(state, {
+ currentId: env.currentId,
+ resIds: env.ids,
+ });
+ },
+ /**
+ * Compute the optional fields local storage key using the given parts.
+ *
+ * @param {Object} keyParts
+ * @param {string} keyParts.viewType view type
+ * @param {string} [keyParts.relationalField] name of the field with subview
+ * @param {integer} [keyParts.subViewId] subview id
+ * @param {string} [keyParts.subViewType] type of the subview
+ * @param {Object} keyParts.fields fields
+ * @param {string} keyParts.fields.name field name
+ * @param {string} keyParts.fields.type field type
+ * @returns {string} local storage key for optional fields in this view
+ * @private
+ */
+ _getOptionalFieldsLocalStorageKey: function (keyParts) {
+ keyParts.model = this.modelName;
+ keyParts.viewType = this.viewType;
+ keyParts.viewId = this.viewId;
+
+ var parts = [
+ 'model',
+ 'viewType',
+ 'viewId',
+ 'relationalField',
+ 'subViewType',
+ 'subViewId',
+ ];
+
+ var viewIdentifier = parts.reduce(function (identifier, partName) {
+ if (partName in keyParts) {
+ return identifier + ',' + keyParts[partName];
+ }
+ return identifier;
+ }, 'optional_fields');
+
+ viewIdentifier =
+ keyParts.fields.sort(this._nameSortComparer)
+ .reduce(function (identifier, field) {
+ return identifier + ',' + field.name;
+ }, viewIdentifier);
+
+ return viewIdentifier;
+ },
+ /**
+ * Return the params (currentMinimum, limit and size) to pass to the pager,
+ * according to the current state.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getPagingInfo: function (state) {
+ const isGrouped = state.groupedBy && state.groupedBy.length;
+ return {
+ currentMinimum: (isGrouped ? state.groupsOffset : state.offset) + 1,
+ limit: isGrouped ? state.groupsLimit : state.limit,
+ size: isGrouped ? state.groupsCount : state.count,
+ };
+ },
+ /**
+ * Return the new actionMenus props.
+ *
+ * @override
+ * @private
+ */
+ _getActionMenuItems: function (state) {
+ return {
+ activeIds: this.getSelectedIds(),
+ context: state.getContext(),
+ };
+ },
+ /**
+ * Sort function used to sort the fields by names, to compute the optional fields keys
+ *
+ * @param {Object} left
+ * @param {Object} right
+ * @private
+ */
+ _nameSortComparer: function(left, right) {
+ return left.name < right.name ? -1 : 1;
+ },
+ /**
+ * Helper function to display a warning that some fields have an invalid
+ * value. This is used when a save operation cannot be completed.
+ *
+ * @private
+ * @param {string[]} invalidFields - list of field names
+ */
+ _notifyInvalidFields: function (invalidFields) {
+ var record = this.model.get(this.handle, {raw: true});
+ var fields = record.fields;
+ var warnings = invalidFields.map(function (fieldName) {
+ var fieldStr = fields[fieldName].string;
+ return _.str.sprintf('<li>%s</li>', _.escape(fieldStr));
+ });
+ warnings.unshift('<ul>');
+ warnings.push('</ul>');
+ this.do_warn(_t("Invalid fields:"), warnings.join(''));
+ },
+ /**
+ * Hook method, called when record(s) has been deleted.
+ *
+ * @see _deleteRecord
+ * @param {string[]} ids list of deleted ids (basic model local handles)
+ */
+ _onDeletedRecords: function (ids) {
+ this.update({});
+ },
+ /**
+ * Saves the record whose ID is given, if necessary. Automatically leaves
+ * edit mode for the given record, unless told otherwise.
+ *
+ * @param {string} [recordID] - default to main recordID
+ * @param {Object} [options]
+ * @param {boolean} [options.stayInEdit=false]
+ * if true, leave the record in edit mode after save
+ * @param {boolean} [options.reload=true]
+ * if true, reload the record after (real) save
+ * @param {boolean} [options.savePoint=false]
+ * if true, the record will only be 'locally' saved: its changes
+ * will move from the _changes key to the data key
+ * @returns {Promise}
+ * Resolved with the list of field names (whose value has been modified)
+ * Rejected if the record can't be saved
+ */
+ _saveRecord: function (recordID, options) {
+ recordID = recordID || this.handle;
+ options = _.defaults(options || {}, {
+ stayInEdit: false,
+ reload: true,
+ savePoint: false,
+ });
+
+ // Check if the view is in a valid state for saving
+ // Note: it is the model's job to do nothing if there is nothing to save
+ if (this.canBeSaved(recordID)) {
+ var self = this;
+ var saveDef = this.model.save(recordID, { // Save then leave edit mode
+ reload: options.reload,
+ savePoint: options.savePoint,
+ viewType: options.viewType,
+ });
+ if (!options.stayInEdit) {
+ saveDef = saveDef.then(function (fieldNames) {
+ var def = fieldNames.length ? self._confirmSave(recordID) : self._setMode('readonly', recordID);
+ return def.then(function () {
+ return fieldNames;
+ });
+ });
+ }
+ return saveDef;
+ } else {
+ return Promise.reject("SaveRecord: this.canBeSave is false"); // Cannot be saved
+ }
+ },
+ /**
+ * Change the mode for the record associated to the given ID.
+ * If the given recordID is the view's main one, then the whole view mode is
+ * changed (@see BasicController.update).
+ *
+ * @private
+ * @param {string} mode - 'readonly' or 'edit'
+ * @param {string} [recordID]
+ * @returns {Promise}
+ */
+ _setMode: function (mode, recordID) {
+ if ((recordID || this.handle) === this.handle) {
+ return this.update({mode: mode}, {reload: false}).then(function () {
+ // necessary to allow all sub widgets to use their dimensions in
+ // layout related activities, such as autoresize on fieldtexts
+ core.bus.trigger('DOM_updated');
+ });
+ }
+ return Promise.resolve();
+ },
+ /**
+ * To override such that it returns true iff the primary action button must
+ * bounce when the user clicked on the given element, according to the
+ * current state of the view.
+ *
+ * @private
+ * @param {HTMLElement} element the node the user clicked on
+ * @returns {boolean}
+ */
+ _shouldBounceOnClick: function (/* element */) {
+ return false;
+ },
+ /**
+ * Helper method, to get the current environment variables from the model
+ * and notifies the component chain (by bubbling an event up)
+ *
+ * @private
+ * @param {Object} [newProps={}]
+ */
+ _updateControlPanel: function (newProps = {}) {
+ const state = this.model.get(this.handle);
+ const props = Object.assign(newProps, {
+ actionMenus: this._getActionMenuItems(state),
+ pager: this._getPagingInfo(state),
+ title: this.getTitle(),
+ });
+ return this.updateControlPanel(props);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the user clicks on the 'content' part of the controller
+ * (typically the renderer area). Makes the first primary button in the
+ * control panel bounce, in some situations (see _shouldBounceOnClick).
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onContentClicked(ev) {
+ if (this.$buttons && this._shouldBounceOnClick(ev.target)) {
+ this.$buttons.find('.btn-primary:visible:first').odooBounce();
+ }
+ },
+ /**
+ * Called when a list element asks to discard the changes made to one of
+ * its rows. It can happen with a x2many (if we are in a form view) or with
+ * a list view.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDiscardChanges: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ var recordID = ev.data.recordID;
+ this._discardChanges(recordID)
+ .then(function () {
+ // TODO this will tell the renderer to rerender the widget that
+ // asked for the discard but will unfortunately lose the click
+ // made on another row if any
+ self._confirmChange(recordID, [ev.data.fieldName], ev)
+ .then(ev.data.onSuccess).guardedCatch(ev.data.onSuccess);
+ })
+ .guardedCatch(ev.data.onFailure);
+ },
+ /**
+ * Forces to save directly the changes if the controller is in readonly,
+ * because in that case the changes come from widgets that are editable even
+ * in readonly (e.g. Priority).
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onFieldChanged: function (ev) {
+ if (this.mode === 'readonly' && !('force_save' in ev.data)) {
+ ev.data.force_save = true;
+ }
+ FieldManagerMixin._onFieldChanged.apply(this, arguments);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onPagerChanged: async function (ev) {
+ ev.stopPropagation();
+ const { currentMinimum, limit } = ev.data;
+ const state = this.model.get(this.handle, { raw: true });
+ const reloadParams = state.groupedBy && state.groupedBy.length ? {
+ groupsLimit: limit,
+ groupsOffset: currentMinimum - 1,
+ } : {
+ limit,
+ offset: currentMinimum - 1,
+ };
+ await this.reload(reloadParams);
+ // reset the scroll position to the top on page changed only
+ if (state.limit === limit) {
+ this.trigger_up('scrollTo', { top: 0 });
+ }
+ },
+ /**
+ * When a reload event triggers up, we need to reload the full view.
+ * For example, after a form view dialog saved some data.
+ *
+ * @todo: rename db_id into handle
+ *
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data
+ * @param {string} [ev.data.db_id] handle of the data to reload and
+ * re-render (reload the whole form by default)
+ * @param {string[]} [ev.data.fieldNames] list of the record's fields to
+ * reload
+ * @param {Function} [ev.data.onSuccess] callback executed after reload is resolved
+ * @param {Function} [ev.data.onFailure] callback executed when reload is rejected
+ */
+ _onReload: function (ev) {
+ ev.stopPropagation(); // prevent other controllers from handling this request
+ var data = ev && ev.data || {};
+ var handle = data.db_id;
+ var prom;
+ if (handle) {
+ // reload the relational field given its db_id
+ prom = this.model.reload(handle).then(this._confirmSave.bind(this, handle));
+ } else {
+ // no db_id given, so reload the main record
+ prom = this.reload({
+ fieldNames: data.fieldNames,
+ keepChanges: data.keepChanges || false,
+ });
+ }
+ prom.then(ev.data.onSuccess).guardedCatch(ev.data.onFailure);
+ },
+ /**
+ * Resequence records in the given order.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {string[]} ev.data.recordIds
+ * @param {integer} ev.data.offset
+ * @param {string} ev.data.handleField
+ */
+ _onResequenceRecords: function (ev) {
+ ev.stopPropagation(); // prevent other controllers from handling this request
+ this.trigger_up('mutexify', {
+ action: async () => {
+ let state = this.model.get(this.handle);
+ const resIDs = ev.data.recordIds
+ .map(recordID => state.data.find(d => d.id === recordID).res_id);
+ const options = {
+ offset: ev.data.offset,
+ field: ev.data.handleField,
+ };
+ await this.model.resequence(this.modelName, resIDs, this.handle, options);
+ this._updateControlPanel();
+ state = this.model.get(this.handle);
+ return this._updateRendererState(state, { noRender: true });
+ },
+ });
+ },
+ /**
+ * Load the optional columns settings in local storage for this view
+ *
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data.keyParts see _getLocalStorageKey
+ * @param {function} ev.data.callback function to call with the result
+ * @private
+ */
+ _onLoadOptionalFields: function (ev) {
+ var res = this.call(
+ 'local_storage',
+ 'getItem',
+ this._getOptionalFieldsLocalStorageKey(ev.data.keyParts)
+ );
+ ev.data.callback(res);
+ },
+ /**
+ * Save the optional columns settings in local storage for this view
+ *
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data.keyParts see _getLocalStorageKey
+ * @param {Array<string>} ev.data.optionalColumnsEnabled list of optional
+ * field names that have been enabled
+ * @private
+ */
+ _onSaveOptionalFields: function (ev) {
+ this.call(
+ 'local_storage',
+ 'setItem',
+ this._getOptionalFieldsLocalStorageKey(ev.data.keyParts),
+ ev.data.optionalColumnsEnabled
+ );
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSetDirty: function (ev) {
+ ev.stopPropagation(); // prevent other controllers from handling this request
+ this.model.setDirty(ev.data.dataPointID);
+ },
+ /**
+ * open the translation view for the current field
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onTranslate: async function (ev) {
+ ev.stopPropagation();
+
+ if (this.model.isNew(ev.data.id)) {
+ await this._confirmSaveNewRecord();
+ var updatedFields = await this.saveRecord(ev.data.id, { stayInEdit: true });
+ await this._confirmChange(ev.data.id, updatedFields, ev);
+ }
+ var record = this.model.get(ev.data.id, { raw: true });
+ var res_id = record.res_id || record.res_ids[0];
+ var result = await this._rpc({
+ route: '/web/dataset/call_button',
+ params: {
+ model: 'ir.translation',
+ method: 'translate_fields',
+ args: [record.model, res_id, ev.data.fieldName],
+ kwargs: { context: record.getContext() },
+ }
+ });
+
+ this.translationDialog = new TranslationDialog(this, {
+ domain: result.domain,
+ searchName: result.context.search_default_name,
+ fieldName: ev.data.fieldName,
+ userLanguageValue: ev.target.value || '',
+ dataPointID: record.id,
+ isComingFromTranslationAlert: ev.data.isComingFromTranslationAlert,
+ isText: result.context.translation_type === 'text',
+ showSrc: result.context.translation_show_src,
+ });
+ return this.translationDialog.open();
+ },
+});
+
+return BasicController;
+});
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
new file mode 100644
index 00000000..1e9868e0
--- /dev/null
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -0,0 +1,5190 @@
+odoo.define('web.BasicModel', function (require) {
+"use strict";
+
+/**
+ * Basic Model
+ *
+ * This class contains all the logic necessary to communicate between the
+ * python models and the web client. More specifically, its job is to give a
+ * simple unified API to the rest of the web client (in particular, the views and
+ * the field widgets) to query and modify actual records in db.
+ *
+ * From a high level perspective, BasicModel is essentially a hashmap with
+ * integer keys and some data and metadata object as value. Each object in this
+ * hashmap represents a piece of data, and can be reloaded and modified by using
+ * its id as key in many methods.
+ *
+ * Here is a description of what those data point look like:
+ * var dataPoint = {
+ * _cache: {Object|undefined}
+ * _changes: {Object|null},
+ * aggregateValues: {Object},
+ * context: {Object},
+ * count: {integer},
+ * data: {Object|Object[]},
+ * domain: {*[]},
+ * fields: {Object},
+ * fieldsInfo: {Object},
+ * getContext: {function},
+ * getDomain: {function},
+ * getFieldNames: {function},
+ * groupedBy: {string[]},
+ * id: {integer},
+ * isOpen: {boolean},
+ * loadMoreOffset: {integer},
+ * limit: {integer},
+ * model: {string},
+ * offset: {integer},
+ * openGroupByDefault: {boolean},
+ * orderedBy: {Object[]},
+ * orderedResIDs: {integer[]},
+ * parentID: {string},
+ * rawContext: {Object},
+ * relationField: {string},
+ * res_id: {integer|null},
+ * res_ids: {integer[]},
+ * specialData: {Object},
+ * _specialDataCache: {Object},
+ * static: {boolean},
+ * type: {string} 'record' | 'list'
+ * value: ?,
+ * };
+ *
+ * Notes:
+ * - id: is totally unrelated to res_id. id is a web client local concept
+ * - res_id: if set to a number or a virtual id (a virtual id is a character
+ * string composed of an integer and has a dash and other information), it
+ * is an actual id for a record in the server database. If set to
+ * 'virtual_' + number, it is a record not yet saved (so, in create mode).
+ * - res_ids: if set, it represent the context in which the data point is actually
+ * used. For example, a given record in a form view (opened from a list view)
+ * might have a res_id = 2 and res_ids = [1,2,3]
+ * - offset: this is mainly used for pagination. Useful when we need to load
+ * another page, then we can simply change the offset and reload.
+ * - count is basically the number of records being manipulated. We can't use
+ * res_ids, because we might have a very large number of records, or a
+ * domain, and the res_ids would be the current page, not the full set.
+ * - model is the actual name of a (odoo) model, such as 'res.partner'
+ * - fields contains the description of all the fields from the model. Note that
+ * these properties might have been modified by a view (for example, with
+ * required=true. So, the fields kind of depends of the context of the
+ * data point.
+ * - field_names: list of some relevant field names (string). Usually, it
+ * denotes the fields present in the view. Only those fields should be
+ * loaded.
+ * - _cache and _changes are private, they should not leak out of the basicModel
+ * and be used by anyone else.
+ *
+ * Commands:
+ * commands are the base commands for x2many (0 -> 6), but with a
+ * slight twist: each [0, _, values] command is augmented with a virtual id:
+ * it means that when the command is added in basicmodel, it generates an id
+ * looking like this: 'virtual_' + number, and uses this id to identify the
+ * element, so it can be edited later.
+ */
+
+var AbstractModel = require('web.AbstractModel');
+var concurrency = require('web.concurrency');
+var Context = require('web.Context');
+var core = require('web.core');
+var Domain = require('web.Domain');
+const pyUtils = require('web.py_utils');
+var session = require('web.session');
+var utils = require('web.utils');
+var viewUtils = require('web.viewUtils');
+var localStorage = require('web.local_storage');
+
+var _t = core._t;
+
+// field types that can be aggregated in grouped views
+const AGGREGATABLE_TYPES = ['float', 'integer', 'monetary'];
+
+var x2ManyCommands = {
+ // (0, virtualID, {values})
+ CREATE: 0,
+ create: function (virtualID, values) {
+ delete values.id;
+ return [x2ManyCommands.CREATE, virtualID || false, values];
+ },
+ // (1, id, {values})
+ UPDATE: 1,
+ update: function (id, values) {
+ delete values.id;
+ return [x2ManyCommands.UPDATE, id, values];
+ },
+ // (2, id[, _])
+ DELETE: 2,
+ delete: function (id) {
+ return [x2ManyCommands.DELETE, id, false];
+ },
+ // (3, id[, _]) removes relation, but not linked record itself
+ FORGET: 3,
+ forget: function (id) {
+ return [x2ManyCommands.FORGET, id, false];
+ },
+ // (4, id[, _])
+ LINK_TO: 4,
+ link_to: function (id) {
+ return [x2ManyCommands.LINK_TO, id, false];
+ },
+ // (5[, _[, _]])
+ DELETE_ALL: 5,
+ delete_all: function () {
+ return [5, false, false];
+ },
+ // (6, _, ids) replaces all linked records with provided ids
+ REPLACE_WITH: 6,
+ replace_with: function (ids) {
+ return [6, false, ids];
+ }
+};
+
+var BasicModel = AbstractModel.extend({
+ // constants
+ OPEN_GROUP_LIMIT: 10, // after this limit, groups are automatically folded
+
+ // list of models for which the DataManager's cache should be cleared on
+ // create, update and delete operations
+ noCacheModels: [
+ 'ir.actions.act_window',
+ 'ir.filters',
+ 'ir.ui.view',
+ ],
+
+ /**
+ * @override
+ */
+ init: function () {
+ // this mutex is necessary to make sure some operations are done
+ // sequentially, for example, an onchange needs to be completed before a
+ // save is performed.
+ this.mutex = new concurrency.Mutex();
+
+ // this array is used to accumulate RPC requests done in the same call
+ // stack, so that they can be batched in the minimum number of RPCs
+ this.batchedRPCsRequests = [];
+
+ this.localData = Object.create(null);
+ // used to generate dataPoint ids. Note that the counter is set to 0 for
+ // each instance, and this is mandatory for the sample data feature to
+ // work: we need both the main model and the sample model to generate the
+ // same datapoint ids for their common data (groups, when there are real
+ // groups in database), so that we can easily do the mapping between
+ // real and sample data.
+ this.__id = 0;
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a default record to a list object. This method actually makes a new
+ * record with the _makeDefaultRecord method, then adds it to the list object.
+ * The default record is added in the data directly. This is meant to be used
+ * by list or kanban controllers (i.e. not for x2manys in form views, as in
+ * this case, we store changes as commands).
+ *
+ * @param {string} listID a valid handle for a list object
+ * @param {Object} [options]
+ * @param {string} [options.position=top] if the new record should be added
+ * on top or on bottom of the list
+ * @returns {Promise<string>} resolves to the id of the new created record
+ */
+ addDefaultRecord: function (listID, options) {
+ var self = this;
+ var list = this.localData[listID];
+ var context = _.extend({}, this._getDefaultContext(list), this._getContext(list));
+
+ var position = (options && options.position) || 'top';
+ var params = {
+ context: context,
+ fields: list.fields,
+ fieldsInfo: list.fieldsInfo,
+ parentID: list.id,
+ position: position,
+ viewType: list.viewType,
+ };
+ return this._makeDefaultRecord(list.model, params).then(function (id) {
+ list.count++;
+ if (position === 'top') {
+ list.data.unshift(id);
+ } else {
+ list.data.push(id);
+ }
+ var record = self.localData[id];
+ list._cache[record.res_id] = id;
+ return id;
+ });
+ },
+ /**
+ * Completes the fields and fieldsInfo of a dataPoint with the given ones.
+ * It is useful for the cases where a record element is shared between
+ * various views, such as a one2many with a tree and a form view.
+ *
+ * @param {string} datapointID a valid element ID (of type 'list' or 'record')
+ * @param {Object} viewInfo
+ * @param {Object} viewInfo.fields
+ * @param {Object} viewInfo.fieldInfo
+ * @param {string} viewInfo.viewType
+ * @returns {Promise} resolved when the fieldInfo have been set on the given
+ * datapoint and all its children, and all rawChanges have been applied
+ */
+ addFieldsInfo: async function (dataPointID, viewInfo) {
+ var dataPoint = this.localData[dataPointID];
+ dataPoint.fields = _.extend({}, dataPoint.fields, viewInfo.fields);
+ // complete the given fieldInfo with the fields of the main view, so
+ // that those field will be reloaded if a reload is triggered by the
+ // secondary view
+ dataPoint.fieldsInfo = dataPoint.fieldsInfo || {};
+ const mainFieldInfo = dataPoint.fieldsInfo[dataPoint[viewInfo.viewType]];
+ dataPoint.fieldsInfo[viewInfo.viewType] = _.defaults({}, viewInfo.fieldInfo, mainFieldInfo);
+
+ // Some fields in the new fields info might not be in the previous one,
+ // so we might have stored changes for them (e.g. coming from onchange
+ // RPCs), that we haven't been able to process earlier (because those
+ // fields were unknown at that time). So we now try to process them.
+ if (dataPoint.type === 'record') {
+ await this.applyRawChanges(dataPointID, viewInfo.viewType);
+ }
+ const proms = [];
+ const fieldInfo = dataPoint.fieldsInfo[viewInfo.viewType];
+ // recursively apply the new field info on sub datapoints
+ if (dataPoint.type === 'list') {
+ // case 'list': on all datapoints in the list
+ Object.values(dataPoint._cache).forEach(subDataPointID => {
+ proms.push(this.addFieldsInfo(subDataPointID, {
+ fields: dataPoint.fields,
+ fieldInfo: dataPoint.fieldsInfo[viewInfo.viewType],
+ viewType: viewInfo.viewType,
+ }));
+ });
+ } else {
+ // case 'record': on datapoints of all x2many fields
+ const values = _.extend({}, dataPoint.data, dataPoint._changes);
+ Object.keys(fieldInfo).forEach(fieldName => {
+ const fieldType = dataPoint.fields[fieldName].type;
+ if (fieldType === 'one2many' || fieldType === 'many2many') {
+ const mode = fieldInfo[fieldName].mode;
+ const views = fieldInfo[fieldName].views;
+ const x2mDataPointID = values[fieldName];
+ if (views[mode] && x2mDataPointID) {
+ proms.push(this.addFieldsInfo(x2mDataPointID, {
+ fields: views[mode].fields,
+ fieldInfo: views[mode].fieldsInfo[mode],
+ viewType: mode,
+ }));
+ }
+ }
+ });
+ }
+ return Promise.all(proms);
+ },
+ /**
+ * Onchange RPCs may return values for fields that are not in the current
+ * view. Those fields might even be unknown when the onchange returns (e.g.
+ * in x2manys, we only know the fields that are used in the inner view, but
+ * not those used in the potential form view opened in a dialog when a sub-
+ * record is clicked). When this happens, we can't infer their type, so the
+ * given value can't be processed. It is instead stored in the '_rawChanges'
+ * key of the record, without any processing. Later on, if this record is
+ * displayed in another view (e.g. the user clicked on it in the x2many
+ * list, and the record opens in a dialog), those changes that were left
+ * behind must be applied. This function applies changes stored in
+ * '_rawChanges' for a given viewType.
+ *
+ * @param {string} recordID local resource id of a record
+ * @param {string} viewType the current viewType
+ * @returns {Promise<string>} resolves to the id of the record
+ */
+ applyRawChanges: function (recordID, viewType) {
+ var record = this.localData[recordID];
+ return this._applyOnChange(record._rawChanges, record, { viewType }).then(function () {
+ return record.id;
+ });
+ },
+ /**
+ * Returns true if a record can be abandoned.
+ *
+ * Case for not abandoning the record:
+ *
+ * 1. flagged as 'no abandon' (i.e. during a `default_get`, including any
+ * `onchange` from a `default_get`)
+ * 2. registered in a list on addition
+ *
+ * 2.1. registered as non-new addition
+ * 2.2. registered as new additon on update
+ *
+ * 3. record is not new
+ *
+ * Otherwise, the record can be abandoned.
+ *
+ * This is useful when discarding changes on this record, as it means that
+ * we must keep the record even if some fields are invalids (e.g. required
+ * field is empty).
+ *
+ * @param {string} id id for a local resource
+ * @returns {boolean}
+ */
+ canBeAbandoned: function (id) {
+ // 1. no drop if flagged
+ if (this.localData[id]._noAbandon) {
+ return false;
+ }
+ // 2. no drop in a list on "ADD in some cases
+ var record = this.localData[id];
+ var parent = this.localData[record.parentID];
+ if (parent) {
+ var entry = _.findWhere(parent._savePoint, {operation: 'ADD', id: id});
+ if (entry) {
+ // 2.1. no drop on non-new addition in list
+ if (!entry.isNew) {
+ return false;
+ }
+ // 2.2. no drop on new addition on "UPDATE"
+ var lastEntry = _.last(parent._savePoint);
+ if (lastEntry.operation === 'UPDATE' && lastEntry.id === id) {
+ return false;
+ }
+ }
+ }
+ // 3. drop new records
+ return this.isNew(id);
+ },
+ /**
+ * Delete a list of records, then, if the records have a parent, reload it.
+ *
+ * @todo we should remove the deleted records from the localData
+ * @todo why can't we infer modelName? Because of grouped datapoint
+ * --> res_id doesn't correspond to the model and we don't have the
+ * information about the related model
+ *
+ * @param {string[]} recordIds list of local resources ids. They should all
+ * be of type 'record', be of the same model and have the same parent.
+ * @param {string} modelName mode name used to unlink the records
+ * @returns {Promise}
+ */
+ deleteRecords: function (recordIds, modelName) {
+ var self = this;
+ var records = _.map(recordIds, function (id) { return self.localData[id]; });
+ var context = _.extend(records[0].getContext(), session.user_context);
+ return this._rpc({
+ model: modelName,
+ method: 'unlink',
+ args: [_.pluck(records, 'res_id')],
+ context: context,
+ })
+ .then(function () {
+ _.each(records, function (record) {
+ var parent = record.parentID && self.localData[record.parentID];
+ if (parent && parent.type === 'list') {
+ parent.data = _.without(parent.data, record.id);
+ delete self.localData[record.id];
+ // Check if we are on last page and all records are deleted from current
+ // page i.e. if there is no state.data.length then go to previous page
+ if (!parent.data.length && parent.offset > 0) {
+ parent.offset = Math.max(parent.offset - parent.limit, 0);
+ }
+ } else {
+ record.res_ids.splice(record.offset, 1);
+ record.offset = Math.min(record.offset, record.res_ids.length - 1);
+ record.res_id = record.res_ids[record.offset];
+ record.count--;
+ }
+ });
+ // optionally clear the DataManager's cache
+ self._invalidateCache(records[0]);
+ });
+ },
+ /**
+ * Discard all changes in a local resource. Basically, it removes
+ * everything that was stored in a _changes key.
+ *
+ * @param {string} id local resource id
+ * @param {Object} [options]
+ * @param {boolean} [options.rollback=false] if true, the changes will
+ * be reset to the last _savePoint, otherwise, they are reset to null
+ */
+ discardChanges: function (id, options) {
+ options = options || {};
+ var element = this.localData[id];
+ var isNew = this.isNew(id);
+ var rollback = 'rollback' in options ? options.rollback : isNew;
+ var initialOffset = element.offset;
+ element._domains = {};
+ this._visitChildren(element, function (elem) {
+ if (rollback && elem._savePoint) {
+ if (elem._savePoint instanceof Array) {
+ elem._changes = elem._savePoint.slice(0);
+ } else {
+ elem._changes = _.extend({}, elem._savePoint);
+ }
+ elem._isDirty = !isNew;
+ } else {
+ elem._changes = null;
+ elem._isDirty = false;
+ }
+ elem.offset = 0;
+ if (elem.tempLimitIncrement) {
+ elem.limit -= elem.tempLimitIncrement;
+ delete elem.tempLimitIncrement;
+ }
+ });
+ element.offset = initialOffset;
+ },
+ /**
+ * Duplicate a record (by calling the 'copy' route)
+ *
+ * @param {string} recordID id for a local resource
+ * @returns {Promise<string>} resolves to the id of duplicate record
+ */
+ duplicateRecord: function (recordID) {
+ var self = this;
+ var record = this.localData[recordID];
+ var context = this._getContext(record);
+ return this._rpc({
+ model: record.model,
+ method: 'copy',
+ args: [record.data.id],
+ context: context,
+ })
+ .then(function (res_id) {
+ var index = record.res_ids.indexOf(record.res_id);
+ record.res_ids.splice(index + 1, 0, res_id);
+ return self.load({
+ fieldsInfo: record.fieldsInfo,
+ fields: record.fields,
+ modelName: record.model,
+ res_id: res_id,
+ res_ids: record.res_ids.slice(0),
+ viewType: record.viewType,
+ context: context,
+ });
+ });
+ },
+ /**
+ * For list resources, this freezes the current records order.
+ *
+ * @param {string} listID a valid element ID of type list
+ */
+ freezeOrder: function (listID) {
+ var list = this.localData[listID];
+ if (list.type === 'record') {
+ return;
+ }
+ list = this._applyX2ManyOperations(list);
+ this._sortList(list);
+ this.localData[listID].orderedResIDs = list.res_ids;
+ },
+ /**
+ * The __get method first argument is the handle returned by the load method.
+ * It is optional (the handle can be undefined). In some case, it makes
+ * sense to use the handle as a key, for example the BasicModel holds the
+ * data for various records, each with its local ID.
+ *
+ * synchronous method, it assumes that the resource has already been loaded.
+ *
+ * @param {string} id local id for the resource
+ * @param {any} options
+ * @param {boolean} [options.env=false] if true, will only return res_id
+ * (if record) or res_ids (if list)
+ * @param {boolean} [options.raw=false] if true, will not follow relations
+ * @returns {Object}
+ */
+ __get: function (id, options) {
+ var self = this;
+ options = options || {};
+
+ if (!(id in this.localData)) {
+ return null;
+ }
+
+ var element = this.localData[id];
+
+ if (options.env) {
+ var env = {
+ ids: element.res_ids ? element.res_ids.slice(0) : [],
+ };
+ if (element.type === 'record') {
+ env.currentId = this.isNew(element.id) ? undefined : element.res_id;
+ }
+ return env;
+ }
+
+ if (element.type === 'record') {
+ var data = _.extend({}, element.data, element._changes);
+ var relDataPoint;
+ for (var fieldName in data) {
+ var field = element.fields[fieldName];
+ if (data[fieldName] === null) {
+ data[fieldName] = false;
+ }
+ if (!field) {
+ continue;
+ }
+
+ // get relational datapoint
+ if (field.type === 'many2one') {
+ if (options.raw) {
+ relDataPoint = this.localData[data[fieldName]];
+ data[fieldName] = relDataPoint ? relDataPoint.res_id : false;
+ } else {
+ data[fieldName] = this.__get(data[fieldName]) || false;
+ }
+ } else if (field.type === 'reference') {
+ if (options.raw) {
+ relDataPoint = this.localData[data[fieldName]];
+ data[fieldName] = relDataPoint ?
+ relDataPoint.model + ',' + relDataPoint.res_id :
+ false;
+ } else {
+ data[fieldName] = this.__get(data[fieldName]) || false;
+ }
+ } else if (field.type === 'one2many' || field.type === 'many2many') {
+ if (options.raw) {
+ if (typeof data[fieldName] === 'string') {
+ relDataPoint = this.localData[data[fieldName]];
+ relDataPoint = this._applyX2ManyOperations(relDataPoint);
+ data[fieldName] = relDataPoint.res_ids;
+ } else {
+ // no datapoint has been created yet (because the loading of relational
+ // data has been batched, and hasn't started yet), so the value is still
+ // the list of ids in the relation
+ data[fieldName] = data[fieldName] || [];
+ }
+ } else {
+ data[fieldName] = this.__get(data[fieldName]) || [];
+ }
+ }
+ }
+ var record = {
+ context: _.extend({}, element.context),
+ count: element.count,
+ data: data,
+ domain: element.domain.slice(0),
+ evalModifiers: element.evalModifiers,
+ fields: element.fields,
+ fieldsInfo: element.fieldsInfo,
+ getContext: element.getContext,
+ getDomain: element.getDomain,
+ getFieldNames: element.getFieldNames,
+ id: element.id,
+ isDirty: element.isDirty,
+ limit: element.limit,
+ model: element.model,
+ offset: element.offset,
+ ref: element.ref,
+ res_ids: element.res_ids.slice(0),
+ specialData: _.extend({}, element.specialData),
+ type: 'record',
+ viewType: element.viewType,
+ };
+
+ if (!this.isNew(element.id)) {
+ record.res_id = element.res_id;
+ }
+ var evalContext;
+ Object.defineProperty(record, 'evalContext', {
+ get: function () {
+ evalContext = evalContext || self._getEvalContext(element);
+ return evalContext;
+ },
+ });
+ return record;
+ }
+
+ // apply potential changes (only for x2many lists)
+ element = this._applyX2ManyOperations(element);
+ this._sortList(element);
+
+ if (!element.orderedResIDs && element._changes) {
+ _.each(element._changes, function (change) {
+ if (change.operation === 'ADD' && change.isNew) {
+ element.data = _.without(element.data, change.id);
+ if (change.position === 'top') {
+ element.data.unshift(change.id);
+ } else {
+ element.data.push(change.id);
+ }
+ }
+ });
+ }
+
+ var list = {
+ aggregateValues: _.extend({}, element.aggregateValues),
+ context: _.extend({}, element.context),
+ count: element.count,
+ data: _.map(element.data, function (elemID) {
+ return self.__get(elemID, options);
+ }),
+ domain: element.domain.slice(0),
+ fields: element.fields,
+ getContext: element.getContext,
+ getDomain: element.getDomain,
+ getFieldNames: element.getFieldNames,
+ groupedBy: element.groupedBy,
+ groupsCount: element.groupsCount,
+ groupsLimit: element.groupsLimit,
+ groupsOffset: element.groupsOffset,
+ id: element.id,
+ isDirty: element.isDirty,
+ isOpen: element.isOpen,
+ isSample: this.isSampleModel,
+ limit: element.limit,
+ model: element.model,
+ offset: element.offset,
+ orderedBy: element.orderedBy,
+ res_id: element.res_id,
+ res_ids: element.res_ids.slice(0),
+ type: 'list',
+ value: element.value,
+ viewType: element.viewType,
+ };
+ if (element.fieldsInfo) {
+ list.fieldsInfo = element.fieldsInfo;
+ }
+ return list;
+ },
+ /**
+ * Generate default values for a given record. Those values are stored in
+ * the '_changes' key of the record. For relational fields, sub-dataPoints
+ * are created, and missing relational data is fetched.
+ * Typically, this function is called when a new record is created. It may
+ * also be called when a one2many subrecord is open in a form view (dialog),
+ * to generate the default values for the fields displayed in the o2m form
+ * view, but not in the list or kanban (mainly to correctly create
+ * sub-dataPoints for relational fields).
+ *
+ * @param {string} recordID local id for a record
+ * @param {Object} [options]
+ * @param {string} [options.viewType] current viewType. If not set, we will
+ * assume main viewType from the record
+ * @param {Array} [options.fieldNames] list of field names for which a
+ * default value must be generated (used to complete the values dict)
+ * @returns {Promise}
+ */
+ generateDefaultValues(recordID, options = {}) {
+ const record = this.localData[recordID];
+ const viewType = options.viewType || record.viewType;
+ const fieldNames = options.fieldNames || Object.keys(record.fieldsInfo[viewType]);
+ const numericFields = ['float', 'integer', 'monetary'];
+ const proms = [];
+ record._changes = record._changes || {};
+ fieldNames.forEach(fieldName => {
+ record.data[fieldName] = null;
+ if (!(fieldName in record._changes)) {
+ const field = record.fields[fieldName];
+ if (numericFields.includes(field.type)) {
+ record._changes[fieldName] = 0;
+ } else if (field.type === 'one2many' || field.type === 'many2many') {
+ proms.push(this._processX2ManyCommands(record, fieldName, [], options));
+ } else {
+ record._changes[fieldName] = null;
+ }
+ }
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * Returns the current display_name for the record.
+ *
+ * @param {string} id the localID for a valid record element
+ * @returns {string}
+ */
+ getName: function (id) {
+ var record = this.localData[id];
+ var returnValue = '';
+ if (record._changes && 'display_name' in record._changes) {
+ returnValue = record._changes.display_name;
+ }
+ else if ('display_name' in record.data) {
+ returnValue = record.data.display_name;
+ }
+ return returnValue || _t("New");
+ },
+ /**
+ * Returns true if a record is dirty. A record is considered dirty if it has
+ * some unsaved changes, marked by the _isDirty property on the record or
+ * one of its subrecords.
+ *
+ * @param {string} id - the local resource id
+ * @returns {boolean}
+ */
+ isDirty: function (id) {
+ var isDirty = false;
+ this._visitChildren(this.localData[id], function (r) {
+ if (r._isDirty) {
+ isDirty = true;
+ }
+ });
+ return isDirty;
+ },
+ /**
+ * Returns true iff the datapoint is of type list and either:
+ * - is not grouped, and contains no records
+ * - is grouped, and contains columns, but all columns are empty
+ * In these cases, we will generate sample data to display, instead of an
+ * empty state.
+ *
+ * @override
+ */
+ _isEmpty(dataPointID) {
+ const dataPoint = this.localData[dataPointID];
+ if (dataPoint.type === 'list') {
+ const hasRecords = dataPoint.count === 0;
+ if (dataPoint.groupedBy.length) {
+ return dataPoint.data.length > 0 && hasRecords;
+ } else {
+ return hasRecords;
+ }
+ }
+ return false;
+ },
+ /**
+ * Check if a localData is new, meaning if it is in the process of being
+ * created and no actual record exists in db. Note: if the localData is not
+ * of the "record" type, then it is always considered as not new.
+ *
+ * Note: A virtual id is a character string composed of an integer and has
+ * a dash and other information.
+ * E.g: in calendar, the recursive event have virtual id linked to a real id
+ * virtual event id "23-20170418020000" is linked to the event id 23
+ *
+ * @param {string} id id for a local resource
+ * @returns {boolean}
+ */
+ isNew: function (id) {
+ var data = this.localData[id];
+ if (data.type !== "record") {
+ return false;
+ }
+ var res_id = data.res_id;
+ if (typeof res_id === 'number') {
+ return false;
+ } else if (typeof res_id === 'string' && /^[0-9]+-/.test(res_id)) {
+ return false;
+ }
+ return true;
+ },
+ /**
+ * Main entry point, the goal of this method is to fetch and process all
+ * data (following relations if necessary) for a given record/list.
+ *
+ * @todo document all params
+ *
+ * @private
+ * @param {any} params
+ * @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field
+ * @param {Object} params.fields contains the description of each field
+ * @param {string} [params.type] 'record' or 'list'
+ * @param {string} [params.recordID] an ID for an existing resource.
+ * @returns {Promise<string>} resolves to a local id, or handle
+ */
+ __load: async function (params) {
+ await this._super(...arguments);
+ params.type = params.type || (params.res_id !== undefined ? 'record' : 'list');
+ // FIXME: the following seems only to be used by the basic_model_tests
+ // so it should probably be removed and the tests should be adapted
+ params.viewType = params.viewType || 'default';
+ if (!params.fieldsInfo) {
+ var fieldsInfo = {};
+ for (var fieldName in params.fieldNames) {
+ fieldsInfo[params.fieldNames[fieldName]] = {};
+ }
+ params.fieldsInfo = {};
+ params.fieldsInfo[params.viewType] = fieldsInfo;
+ }
+
+ if (params.type === 'record' && params.res_id === undefined) {
+ params.allowWarning = true;
+ return this._makeDefaultRecord(params.modelName, params);
+ }
+ var dataPoint = this._makeDataPoint(params);
+ return this._load(dataPoint).then(function () {
+ return dataPoint.id;
+ });
+ },
+ /**
+ * Returns the list of res_ids for a given list of local ids.
+ *
+ * @param {string[]} localIds
+ * @returns {integer[]}
+ */
+ localIdsToResIds: function (localIds) {
+ return localIds.map(localId => this.localData[localId].res_id);
+ },
+ /**
+ * This helper method is designed to help developpers that want to use a
+ * field widget outside of a view. In that case, we want a way to create
+ * data without actually performing a fetch.
+ *
+ * @param {string} model name of the model
+ * @param {Object[]} fields a description of field properties
+ * @param {Object} [fieldInfo] various field info that we want to set
+ * @returns {Promise<string>} the local id for the created resource
+ */
+ makeRecord: function (model, fields, fieldInfo) {
+ var self = this;
+ var defs = [];
+ var record_fields = {};
+ _.each(fields, function (field) {
+ record_fields[field.name] = _.pick(field, 'type', 'relation', 'domain', 'selection');
+ });
+ fieldInfo = fieldInfo || {};
+ var fieldsInfo = {};
+ fieldsInfo.default = {};
+ _.each(fields, function (field) {
+ fieldsInfo.default[field.name] = fieldInfo[field.name] || {};
+ });
+ var record = this._makeDataPoint({
+ modelName: model,
+ fields: record_fields,
+ fieldsInfo: fieldsInfo,
+ viewType: 'default',
+ });
+ _.each(fields, function (field) {
+ var dataPoint;
+ record.data[field.name] = null;
+ if (field.type === 'many2one') {
+ if (field.value) {
+ var id = _.isArray(field.value) ? field.value[0] : field.value;
+ var display_name = _.isArray(field.value) ? field.value[1] : undefined;
+ dataPoint = self._makeDataPoint({
+ modelName: field.relation,
+ data: {
+ id: id,
+ display_name: display_name,
+ },
+ parentID: record.id,
+ });
+ record.data[field.name] = dataPoint.id;
+ if (display_name === undefined) {
+ defs.push(self._fetchNameGet(dataPoint));
+ }
+ }
+ } else if (field.type === 'reference' && field.value) {
+ const ref = field.value.split(',');
+ dataPoint = self._makeDataPoint({
+ context: record.context,
+ data: { id: parseInt(ref[1], 10) },
+ modelName: ref[0],
+ parentID: record.id,
+ });
+ defs.push(self._fetchNameGet(dataPoint));
+ record.data[field.name] = dataPoint.id;
+ } else if (field.type === 'one2many' || field.type === 'many2many') {
+ var relatedFieldsInfo = {};
+ relatedFieldsInfo.default = {};
+ _.each(field.fields, function (field) {
+ relatedFieldsInfo.default[field.name] = {};
+ });
+ var dpParams = {
+ fieldsInfo: relatedFieldsInfo,
+ modelName: field.relation,
+ parentID: record.id,
+ static: true,
+ type: 'list',
+ viewType: 'default',
+ };
+ var needLoad = false;
+ // As value, you could either pass:
+ // - a list of ids related to the record
+ // - a list of object
+ // We only need to load the datapoint in the first case.
+ if (field.value && field.value.length) {
+ if (_.isObject(field.value[0])) {
+ dpParams.res_ids = _.pluck(field.value, 'id');
+ dataPoint = self._makeDataPoint(dpParams);
+ _.each(field.value, function (data) {
+ var recordDP = self._makeDataPoint({
+ data: data,
+ modelName: field.relation,
+ parentID: dataPoint.id,
+ type: 'record',
+ });
+ dataPoint.data.push(recordDP.id);
+ dataPoint._cache[recordDP.res_id] = recordDP.id;
+ });
+ } else {
+ dpParams.res_ids = field.value;
+ dataPoint = self._makeDataPoint(dpParams);
+ needLoad = true;
+ }
+ } else {
+ dpParams.res_ids = [];
+ dataPoint = self._makeDataPoint(dpParams);
+ }
+
+ if (needLoad) {
+ defs.push(self._load(dataPoint));
+ }
+ record.data[field.name] = dataPoint.id;
+ } else if (field.value) {
+ record.data[field.name] = field.value;
+ }
+ });
+ return Promise.all(defs).then(function () {
+ return record.id;
+ });
+ },
+ /**
+ * This is an extremely important method. All changes in any field go
+ * through this method. It will then apply them in the local state, check
+ * if onchanges needs to be applied, actually do them if necessary, then
+ * resolves with the list of changed fields.
+ *
+ * @param {string} record_id
+ * @param {Object} changes a map field => new value
+ * @param {Object} [options] will be transferred to the applyChange method
+ * @see _applyChange
+ * @returns {Promise<string[]>} list of changed fields
+ */
+ notifyChanges: function (record_id, changes, options) {
+ return this.mutex.exec(this._applyChange.bind(this, record_id, changes, options));
+ },
+ /**
+ * Reload all data for a given resource. At any time there is at most one
+ * reload operation active.
+ *
+ * @private
+ * @param {string} id local id for a resource
+ * @param {Object} [options]
+ * @param {boolean} [options.keepChanges=false] if true, doesn't discard the
+ * changes on the record before reloading it
+ * @returns {Promise<string>} resolves to the id of the resource
+ */
+ __reload: async function (id, options) {
+ await this._super(...arguments);
+ return this.mutex.exec(this._reload.bind(this, id, options));
+ },
+ /**
+ * In some case, we may need to remove an element from a list, without going
+ * through the notifyChanges machinery. The motivation for this is when the
+ * user click on 'Add a line' in a field one2many with a required field,
+ * then clicks somewhere else. The new line need to be discarded, but we
+ * don't want to trigger a real notifyChanges (no need for that, and also,
+ * we don't want to rerender the UI).
+ *
+ * @param {string} elementID some valid element id. It is necessary that the
+ * corresponding element has a parent.
+ */
+ removeLine: function (elementID) {
+ var record = this.localData[elementID];
+ var parent = this.localData[record.parentID];
+ if (parent.static) {
+ // x2Many case: the new record has been stored in _changes, as a
+ // command so we remove the command(s) related to that record
+ parent._changes = _.filter(parent._changes, function (change) {
+ if (change.id === elementID &&
+ change.operation === 'ADD' && // For now, only an ADD command increases limits
+ parent.tempLimitIncrement) {
+ // The record will be deleted from the _changes.
+ // So we won't be passing into the logic of _applyX2ManyOperations anymore
+ // implying that we have to cancel out the effects of an ADD command here
+ parent.tempLimitIncrement--;
+ parent.limit--;
+ }
+ return change.id !== elementID;
+ });
+ } else {
+ // main list view case: the new record is in data
+ parent.data = _.without(parent.data, elementID);
+ parent.count--;
+ }
+ },
+ /**
+ * Resequences records.
+ *
+ * @param {string} modelName the resIDs model
+ * @param {Array<integer>} resIDs the new sequence of ids
+ * @param {string} parentID the localID of the parent
+ * @param {object} [options]
+ * @param {integer} [options.offset]
+ * @param {string} [options.field] the field name used as sequence
+ * @returns {Promise<string>} resolves to the local id of the parent
+ */
+ resequence: function (modelName, resIDs, parentID, options) {
+ options = options || {};
+ if ((resIDs.length <= 1)) {
+ return Promise.resolve(parentID); // there is nothing to sort
+ }
+ var self = this;
+ var data = this.localData[parentID];
+ var params = {
+ model: modelName,
+ ids: resIDs,
+ };
+ if (options.offset) {
+ params.offset = options.offset;
+ }
+ if (options.field) {
+ params.field = options.field;
+ }
+ return this._rpc({
+ route: '/web/dataset/resequence',
+ params: params,
+ })
+ .then(function (wasResequenced) {
+ if (!wasResequenced) {
+ // the field on which the resequence was triggered does not
+ // exist, so no resequence happened server-side
+ return Promise.resolve();
+ }
+ var field = params.field ? params.field : 'sequence';
+
+ return self._rpc({
+ model: modelName,
+ method: 'read',
+ args: [resIDs, [field]],
+ }).then(function (records) {
+ if (data.data.length) {
+ var dataType = self.localData[data.data[0]].type;
+ if (dataType === 'record') {
+ _.each(data.data, function (dataPoint) {
+ var recordData = self.localData[dataPoint].data;
+ var inRecords = _.findWhere(records, {id: recordData.id});
+ if (inRecords) {
+ recordData[field] = inRecords[field];
+ }
+ });
+ data.data = _.sortBy(data.data, function (d) {
+ return self.localData[d].data[field];
+ });
+ }
+ if (dataType === 'list') {
+ data.data = _.sortBy(data.data, function (d) {
+ return _.indexOf(resIDs, self.localData[d].res_id)
+ });
+ }
+ }
+ data.res_ids = [];
+ _.each(data.data, function (d) {
+ var dataPoint = self.localData[d];
+ if (dataPoint.type === 'record') {
+ data.res_ids.push(dataPoint.res_id);
+ } else {
+ data.res_ids = data.res_ids.concat(dataPoint.res_ids);
+ }
+ });
+ self._updateParentResIDs(data);
+ return parentID;
+ })
+ });
+ },
+ /**
+ * Save a local resource, if needed. This is a complicated operation,
+ * - it needs to check all changes,
+ * - generate commands for x2many fields,
+ * - call the /create or /write method according to the record status
+ * - After that, it has to reload all data, in case something changed, server side.
+ *
+ * @param {string} recordID local resource
+ * @param {Object} [options]
+ * @param {boolean} [options.reload=true] if true, data will be reloaded
+ * @param {boolean} [options.savePoint=false] if true, the record will only
+ * be 'locally' saved: its changes written in a _savePoint key that can
+ * be restored later by call discardChanges with option rollback to true
+ * @param {string} [options.viewType] current viewType. If not set, we will
+ * assume main viewType from the record
+ * @returns {Promise}
+ * Resolved with the list of field names (whose value has been modified)
+ */
+ save: function (recordID, options) {
+ var self = this;
+ return this.mutex.exec(function () {
+ options = options || {};
+ var record = self.localData[recordID];
+ if (options.savePoint) {
+ self._visitChildren(record, function (rec) {
+ var newValue = rec._changes || rec.data;
+ if (newValue instanceof Array) {
+ rec._savePoint = newValue.slice(0);
+ } else {
+ rec._savePoint = _.extend({}, newValue);
+ }
+ });
+
+ // save the viewType of edition, so that the correct readonly modifiers
+ // can be evaluated when the record will be saved
+ for (let fieldName in (record._changes || {})) {
+ record._editionViewType[fieldName] = options.viewType;
+ }
+ }
+ var shouldReload = 'reload' in options ? options.reload : true;
+ var method = self.isNew(recordID) ? 'create' : 'write';
+ if (record._changes) {
+ // id never changes, and should not be written
+ delete record._changes.id;
+ }
+ var changes = self._generateChanges(record, {viewType: options.viewType, changesOnly: method !== 'create'});
+
+ // id field should never be written/changed
+ delete changes.id;
+
+ if (method === 'create') {
+ var fieldNames = record.getFieldNames();
+ _.each(fieldNames, function (name) {
+ if (changes[name] === null) {
+ delete changes[name];
+ }
+ });
+ }
+
+ var prom = new Promise(function (resolve, reject) {
+ var changedFields = Object.keys(changes);
+
+ if (options.savePoint) {
+ resolve(changedFields);
+ return;
+ }
+
+ // in the case of a write, only perform the RPC if there are changes to save
+ if (method === 'create' || changedFields.length) {
+ var args = method === 'write' ? [[record.data.id], changes] : [changes];
+ self._rpc({
+ model: record.model,
+ method: method,
+ args: args,
+ context: record.getContext(),
+ }).then(function (id) {
+ if (method === 'create') {
+ record.res_id = id; // create returns an id, write returns a boolean
+ record.data.id = id;
+ record.offset = record.res_ids.length;
+ record.res_ids.push(id);
+ record.count++;
+ }
+
+ var _changes = record._changes;
+
+ // Erase changes as they have been applied
+ record._changes = {};
+
+ // Optionally clear the DataManager's cache
+ self._invalidateCache(record);
+
+ self.unfreezeOrder(record.id);
+
+ // Update the data directly or reload them
+ if (shouldReload) {
+ self._fetchRecord(record).then(function () {
+ resolve(changedFields);
+ });
+ } else {
+ _.extend(record.data, _changes);
+ resolve(changedFields);
+ }
+ }).guardedCatch(reject);
+ } else {
+ resolve(changedFields);
+ }
+ });
+ prom.then(function () {
+ record._isDirty = false;
+ });
+ return prom;
+ });
+ },
+ /**
+ * Manually sets a resource as dirty. This is used to notify that a field
+ * has been modified, but with an invalid value. In that case, the value is
+ * not sent to the basic model, but the record should still be flagged as
+ * dirty so that it isn't discarded without any warning.
+ *
+ * @param {string} id a resource id
+ */
+ setDirty: function (id) {
+ this.localData[id]._isDirty = true;
+ },
+ /**
+ * For list resources, this changes the orderedBy key.
+ *
+ * @param {string} list_id id for the list resource
+ * @param {string} fieldName valid field name
+ * @returns {Promise}
+ */
+ setSort: function (list_id, fieldName) {
+ var list = this.localData[list_id];
+ if (list.type === 'record') {
+ return;
+ } else if (list._changes) {
+ _.each(list._changes, function (change) {
+ delete change.isNew;
+ });
+ }
+ if (list.orderedBy.length === 0) {
+ list.orderedBy.push({name: fieldName, asc: true});
+ } else if (list.orderedBy[0].name === fieldName){
+ if (!list.orderedResIDs) {
+ list.orderedBy[0].asc = !list.orderedBy[0].asc;
+ }
+ } else {
+ var orderedBy = _.reject(list.orderedBy, function (o) {
+ return o.name === fieldName;
+ });
+ list.orderedBy = [{name: fieldName, asc: true}].concat(orderedBy);
+ }
+
+ list.orderedResIDs = null;
+ if (list.static) {
+ // sorting might require to fetch the field for records where the
+ // sort field is still unknown (i.e. on other pages for example)
+ return this._fetchUngroupedList(list);
+ }
+ return Promise.resolve();
+ },
+ /**
+ * For a given resource of type 'record', get the active field, if any.
+ *
+ * Since the ORM can support both `active` and `x_active` fields for
+ * the archiving mechanism, check if any such field exists and prioritize
+ * them. The `active` field should always take priority over its custom
+ * version.
+ *
+ * @param {Object} record local resource
+ * @returns {String|undefined} the field name to use for archiving purposes
+ * ('active', 'x_active') or undefined if no such field is present
+ */
+ getActiveField: function (record) {
+ const fields = Object.keys(record.fields);
+ const has_active = fields.includes('active');
+ if (has_active) {
+ return 'active';
+ }
+ const has_x_active = fields.includes('x_active');
+ return has_x_active?'x_active':undefined
+ },
+ /**
+ * Toggle the active value of given records (to archive/unarchive them)
+ *
+ * @param {Array} recordIDs local ids of the records to (un)archive
+ * @param {boolean} value false to archive, true to unarchive (value of the active field)
+ * @param {string} parentID id of the parent resource to reload
+ * @returns {Promise<string>} resolves to the parent id
+ */
+ toggleActive: function (recordIDs, parentID) {
+ var self = this;
+ var parent = this.localData[parentID];
+ var resIDs = _.map(recordIDs, function (recordID) {
+ return self.localData[recordID].res_id;
+ });
+ return this._rpc({
+ model: parent.model,
+ method: 'toggle_active',
+ args: [resIDs],
+ })
+ .then(function (action) {
+ // optionally clear the DataManager's cache
+ self._invalidateCache(parent);
+ if (!_.isEmpty(action)) {
+ return self.do_action(action, {
+ on_close: function () {
+ return self.trigger_up('reload');
+ }
+ });
+ } else {
+ return self.reload(parentID);
+ }
+ });
+ },
+ /**
+ * Archive the given records
+ *
+ * @param {integer[]} resIDs ids of the records to archive
+ * @param {string} parentID id of the parent resource to reload
+ * @returns {Promise<string>} resolves to the parent id
+ */
+ actionArchive: function (resIDs, parentID) {
+ var self = this;
+ var parent = this.localData[parentID];
+ return this._rpc({
+ model: parent.model,
+ method: 'action_archive',
+ args: [resIDs],
+ })
+ .then(function (action) {
+ // optionally clear the DataManager's cache
+ self._invalidateCache(parent);
+ if (!_.isEmpty(action)) {
+ return new Promise(function (resolve, reject) {
+ self.do_action(action, {
+ on_close: function (result) {
+ return self.trigger_up('reload', {
+ onSuccess: resolve,
+ });
+ }
+ });
+ });
+ } else {
+ return self.reload(parentID);
+ }
+ }).then(function (datapoint) {
+ // if there are no records to display and we are not on first page(we check it
+ // by checking offset is greater than limit i.e. we are not on first page)
+ // reason for adding logic after reload to make sure there is no records after operation
+ if (parent && parent.type === 'list' && !parent.data.length && parent.offset > 0) {
+ parent.offset = Math.max(parent.offset - parent.limit, 0);
+ return self.reload(parentID);
+ }
+ return datapoint;
+ });
+ },
+ /**
+ * Unarchive the given records
+ *
+ * @param {integer[]} resIDs ids of the records to unarchive
+ * @param {string} parentID id of the parent resource to reload
+ * @returns {Promise<string>} resolves to the parent id
+ */
+ actionUnarchive: function (resIDs, parentID) {
+ var self = this;
+ var parent = this.localData[parentID];
+ return this._rpc({
+ model: parent.model,
+ method: 'action_unarchive',
+ args: [resIDs],
+ })
+ .then(function (action) {
+ // optionally clear the DataManager's cache
+ self._invalidateCache(parent);
+ if (!_.isEmpty(action)) {
+ return new Promise(function (resolve, reject) {
+ self.do_action(action, {
+ on_close: function () {
+ return self.trigger_up('reload', {
+ onSuccess: resolve,
+ });
+ }
+ });
+ });
+ } else {
+ return self.reload(parentID);
+ }
+ }).then(function (datapoint) {
+ // if there are no records to display and we are not on first page(we check it
+ // by checking offset is greater than limit i.e. we are not on first page)
+ // reason for adding logic after reload to make sure there is no records after operation
+ if (parent && parent.type === 'list' && !parent.data.length && parent.offset > 0) {
+ parent.offset = Math.max(parent.offset - parent.limit, 0);
+ return self.reload(parentID);
+ }
+ return datapoint;
+ });
+ },
+ /**
+ * Toggle (open/close) a group in a grouped list, then fetches relevant
+ * data
+ *
+ * @param {string} groupId
+ * @returns {Promise<string>} resolves to the group id
+ */
+ toggleGroup: function (groupId) {
+ var self = this;
+ var group = this.localData[groupId];
+ if (group.isOpen) {
+ group.isOpen = false;
+ group.data = [];
+ group.res_ids = [];
+ group.offset = 0;
+ this._updateParentResIDs(group);
+ return Promise.resolve(groupId);
+ }
+ if (!group.isOpen) {
+ group.isOpen = true;
+ var def;
+ if (group.count > 0) {
+ def = this._load(group).then(function () {
+ self._updateParentResIDs(group);
+ });
+ }
+ return Promise.resolve(def).then(function () {
+ return groupId;
+ });
+ }
+ },
+ /**
+ * For a list datapoint, unfreezes the current records order and sorts it.
+ * For a record datapoint, unfreezes the x2many list datapoints.
+ *
+ * @param {string} elementID a valid element ID
+ */
+ unfreezeOrder: function (elementID) {
+ var list = this.localData[elementID];
+ if (list.type === 'record') {
+ var data = _.extend({}, list.data, list._changes);
+ for (var fieldName in data) {
+ var field = list.fields[fieldName];
+ if (!field || !data[fieldName]) {
+ continue;
+ }
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ var recordlist = this.localData[data[fieldName]];
+ recordlist.orderedResIDs = null;
+ for (var index in recordlist.data) {
+ this.unfreezeOrder(recordlist.data[index]);
+ }
+ }
+ }
+ return;
+ }
+ list.orderedResIDs = null;
+ this._sortList(list);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a default record to a list object. This method actually makes a new
+ * record with the _makeDefaultRecord method, then adds it to the list object
+ * as a 'ADD' command in its _changes. This is meant to be used x2many lists,
+ * not by list or kanban controllers.
+ *
+ * @private
+ * @param {Object} list a valid list object
+ * @param {Object} [options]
+ * @param {string} [options.position=top] if the new record should be added
+ * on top or on bottom of the list
+ * @param {Array} [options.[context]] additional context to be merged before
+ * calling the default_get (eg. to set default values).
+ * If several contexts are found, multiple records are added
+ * @param {boolean} [options.allowWarning=false] if true, the default record
+ * operation can complete, even if a warning is raised
+ * @returns {Promise<[string]>} resolves to the new records ids
+ */
+ _addX2ManyDefaultRecord: function (list, options) {
+ var self = this;
+ var position = options && options.position || 'top';
+ var params = {
+ fields: list.fields,
+ fieldsInfo: list.fieldsInfo,
+ parentID: list.id,
+ position: position,
+ viewType: options.viewType || list.viewType,
+ allowWarning: options && options.allowWarning
+ };
+
+ var additionalContexts = options && options.context;
+ var makeDefaultRecords = [];
+ if (additionalContexts){
+ _.each(additionalContexts, function (context) {
+ params.context = self._getContext(list, {additionalContext: context});
+ makeDefaultRecords.push(self._makeDefaultRecord(list.model, params));
+ });
+ } else {
+ params.context = self._getContext(list);
+ makeDefaultRecords.push(self._makeDefaultRecord(list.model, params));
+ }
+
+ return Promise.all(makeDefaultRecords).then(function (resultIds){
+ var ids = [];
+ _.each(resultIds, function (id){
+ ids.push(id);
+
+ list._changes.push({operation: 'ADD', id: id, position: position, isNew: true});
+ var record = self.localData[id];
+ list._cache[record.res_id] = id;
+ if (list.orderedResIDs) {
+ var index = list.offset + (position !== 'top' ? list.limit : 0);
+ list.orderedResIDs.splice(index, 0, record.res_id);
+ // list could be a copy of the original one
+ self.localData[list.id].orderedResIDs = list.orderedResIDs;
+ }
+ });
+
+ return ids;
+ });
+ },
+ /**
+ * This method is the private version of notifyChanges. Unlike
+ * notifyChanges, it is not protected by a mutex. Every changes from the
+ * user to the model go through this method.
+ *
+ * @param {string} recordID
+ * @param {Object} changes
+ * @param {Object} [options]
+ * @param {boolean} [options.doNotSetDirty=false] if this flag is set to
+ * true, then we will not tag the record as dirty. This should be avoided
+ * for most situations.
+ * @param {boolean} [options.notifyChange=true] if this flag is set to
+ * false, then we will not notify and not trigger the onchange, even though
+ * it was changed.
+ * @param {string} [options.viewType] current viewType. If not set, we will assume
+ * main viewType from the record
+ * @param {boolean} [options.allowWarning=false] if true, change
+ * operation can complete, even if a warning is raised
+ * (only supported by X2ManyChange)
+ * @returns {Promise} list of changed fields
+ */
+ _applyChange: function (recordID, changes, options) {
+ var self = this;
+ var record = this.localData[recordID];
+ var field;
+ var defs = [];
+ options = options || {};
+ record._changes = record._changes || {};
+ if (!options.doNotSetDirty) {
+ record._isDirty = true;
+ }
+ var initialData = {};
+ this._visitChildren(record, function (elem) {
+ initialData[elem.id] = $.extend(true, {}, _.pick(elem, 'data', '_changes'));
+ });
+
+ // apply changes to local data
+ for (var fieldName in changes) {
+ field = record.fields[fieldName];
+ if (field && (field.type === 'one2many' || field.type === 'many2many')) {
+ defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options));
+ } else if (field && (field.type === 'many2one' || field.type === 'reference')) {
+ defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName], options));
+ } else {
+ record._changes[fieldName] = changes[fieldName];
+ }
+ }
+
+ if (options.notifyChange === false) {
+ return Promise.all(defs).then(function () {
+ return Promise.resolve(_.keys(changes));
+ });
+ }
+
+ return Promise.all(defs).then(function () {
+ var onChangeFields = []; // the fields that have changed and that have an on_change
+ for (var fieldName in changes) {
+ field = record.fields[fieldName];
+ if (field && field.onChange) {
+ var isX2Many = field.type === 'one2many' || field.type === 'many2many';
+ if (!isX2Many || (self._isX2ManyValid(record._changes[fieldName] || record.data[fieldName]))) {
+ onChangeFields.push(fieldName);
+ }
+ }
+ }
+ return new Promise(function (resolve, reject) {
+ if (onChangeFields.length) {
+ self._performOnChange(record, onChangeFields, { viewType: options.viewType })
+ .then(function (result) {
+ delete record._warning;
+ resolve(_.keys(changes).concat(Object.keys(result && result.value || {})));
+ }).guardedCatch(function () {
+ self._visitChildren(record, function (elem) {
+ _.extend(elem, initialData[elem.id]);
+ });
+ reject();
+ });
+ } else {
+ resolve(_.keys(changes));
+ }
+ }).then(function (fieldNames) {
+ return self._fetchSpecialData(record).then(function (fieldNames2) {
+ // Return the names of the fields that changed (onchange or
+ // associated special data change)
+ return _.union(fieldNames, fieldNames2);
+ });
+ });
+ });
+ },
+ /**
+ * Apply an x2one (either a many2one or a reference field) change. There is
+ * a need for this function because the server only gives an id when a
+ * onchange modifies a many2one field. For this reason, we need (sometimes)
+ * to do a /name_get to fetch a display_name.
+ *
+ * Moreover, for the many2one case, a new value can sometimes be set (i.e.
+ * a display_name is given, but no id). When this happens, we first do a
+ * name_create.
+ *
+ * @param {Object} record
+ * @param {string} fieldName
+ * @param {Object} [data]
+ * @param {Object} [options]
+ * @param {string} [options.viewType] current viewType. If not set, we will
+ * assume main viewType from the record
+ * @returns {Promise}
+ */
+ _applyX2OneChange: async function (record, fieldName, data, options) {
+ options = options || {};
+ var self = this;
+ if (!data || (!data.id && !data.display_name)) {
+ record._changes[fieldName] = false;
+ return Promise.resolve();
+ }
+
+ const field = record.fields[fieldName];
+ const coModel = field.type === 'reference' ? data.model : field.relation;
+ const allowedTypes = ['many2one', 'reference'];
+ if (allowedTypes.includes(field.type) && !data.id && data.display_name) {
+ // only display_name given -> do a name_create
+ const result = await this._rpc({
+ model: coModel,
+ method: 'name_create',
+ args: [data.display_name],
+ context: this._getContext(record, {fieldName: fieldName, viewType: options.viewType}),
+ });
+ // Check if a record is really created. Models without defined
+ // _rec_name cannot create record based on name_create.
+ if (!result) {
+ record._changes[fieldName] = false;
+ return Promise.resolve();
+ }
+ data = {id: result[0], display_name: result[1]};
+ }
+
+ // here, we check that the many2one really changed. If the res_id is the
+ // same, we do not need to do any extra work. It can happen when the
+ // user edited a manyone (with the small form view button) with an
+ // onchange. In that case, the onchange is triggered, but the actual
+ // value did not change.
+ var relatedID;
+ if (record._changes && fieldName in record._changes) {
+ relatedID = record._changes[fieldName];
+ } else {
+ relatedID = record.data[fieldName];
+ }
+ var relatedRecord = this.localData[relatedID];
+ if (relatedRecord && (data.id === this.localData[relatedID].res_id)) {
+ return Promise.resolve();
+ }
+ var rel_data = _.pick(data, 'id', 'display_name');
+
+ // the reference field doesn't store its co-model in its field metadata
+ // but directly in the data (as the co-model isn't fixed)
+ var def;
+ if (rel_data.display_name === undefined) {
+ // TODO: refactor this to use _fetchNameGet
+ def = this._rpc({
+ model: coModel,
+ method: 'name_get',
+ args: [data.id],
+ context: record.context,
+ })
+ .then(function (result) {
+ rel_data.display_name = result[0][1];
+ });
+ }
+ return Promise.resolve(def).then(function () {
+ var rec = self._makeDataPoint({
+ context: record.context,
+ data: rel_data,
+ fields: {},
+ fieldsInfo: {},
+ modelName: coModel,
+ parentID: record.id,
+ });
+ record._changes[fieldName] = rec.id;
+ });
+ },
+ /**
+ * Applies the result of an onchange RPC on a record.
+ *
+ * @private
+ * @param {Object} values the result of the onchange RPC (a mapping of
+ * fieldnames to their value)
+ * @param {Object} record
+ * @param {Object} [options={}]
+ * @param {string} [options.viewType] current viewType. If not set, we will assume
+ * main viewType from the record
+ * @param {string} [options.firstOnChange] set to true if this is the first
+ * onchange (if so, some initialization will need to be done)
+ * @returns {Promise}
+ */
+ _applyOnChange: function (values, record, options = {}) {
+ var self = this;
+ var defs = [];
+ var rec;
+ const viewType = options.viewType || record.viewType;
+ record._changes = record._changes || {};
+
+ for (let name in (values || {})) {
+ const val = values[name];
+ var field = record.fields[name];
+ if (!field) {
+ // this field is unknown so we can't process it for now (it is not
+ // in the current view anyway, otherwise it wouldn't be unknown.
+ // we store its value without processing it, so that if we later
+ // on switch to another view in which this field is displayed,
+ // we could process it as we would know its type then.
+ // use case: an onchange sends a create command for a one2many,
+ // in the dict of values, there is a value for a field that is
+ // not in the one2many list, but that is in the one2many form.
+ record._rawChanges[name] = val;
+ // LPE TODO 1 taskid-2261084: remove this entire comment including code snippet
+ // when the change in behavior has been thoroughly tested.
+ // It is impossible to distinguish between values returned by the default_get
+ // and those returned by the onchange. Since those are not in _changes, they won't be saved.
+ // if (options.firstOnChange) {
+ // record._changes[name] = val;
+ // }
+ continue;
+ }
+ if (record._rawChanges[name]) {
+ // if previous _rawChanges exists, clear them since the field is now knwon
+ // and restoring outdated onchange over posterious change is wrong
+ delete record._rawChanges[name];
+ }
+ var oldValue = name in record._changes ? record._changes[name] : record.data[name];
+ var id;
+ if (field.type === 'many2one') {
+ id = false;
+ // in some case, the value returned by the onchange can
+ // be false (no value), so we need to avoid creating a
+ // local record for that.
+ if (val) {
+ // when the value isn't false, it can be either
+ // an array [id, display_name] or just an id.
+ var data = _.isArray(val) ?
+ {id: val[0], display_name: val[1]} :
+ {id: val};
+ if (!oldValue || (self.localData[oldValue].res_id !== data.id)) {
+ // only register a change if the value has changed
+ rec = self._makeDataPoint({
+ context: record.context,
+ data: data,
+ modelName: field.relation,
+ parentID: record.id,
+ });
+ id = rec.id;
+ record._changes[name] = id;
+ }
+ } else {
+ record._changes[name] = false;
+ }
+ } else if (field.type === 'reference') {
+ id = false;
+ if (val) {
+ var ref = val.split(',');
+ var modelName = ref[0];
+ var resID = parseInt(ref[1]);
+ if (!oldValue || self.localData[oldValue].res_id !== resID ||
+ self.localData[oldValue].model !== modelName) {
+ // only register a change if the value has changed
+ rec = self._makeDataPoint({
+ context: record.context,
+ data: {id: parseInt(ref[1])},
+ modelName: modelName,
+ parentID: record.id,
+ });
+ defs.push(self._fetchNameGet(rec));
+ id = rec.id;
+ record._changes[name] = id;
+ }
+ } else {
+ record._changes[name] = id;
+ }
+ } else if (field.type === 'one2many' || field.type === 'many2many') {
+ var listId = record._changes[name] || record.data[name];
+ var list;
+ if (listId) {
+ list = self.localData[listId];
+ } else {
+ var fieldInfo = record.fieldsInfo[viewType][name];
+ if (!fieldInfo) {
+ // ignore changes of x2many not in view
+ continue;
+ }
+ var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
+ list = self._makeDataPoint({
+ fields: view ? view.fields : fieldInfo.relatedFields,
+ fieldsInfo: view ? view.fieldsInfo : fieldInfo.fieldsInfo,
+ limit: fieldInfo.limit,
+ modelName: field.relation,
+ parentID: record.id,
+ static: true,
+ type: 'list',
+ viewType: view ? view.type : fieldInfo.viewType,
+ });
+ }
+ // TODO: before registering the changes, verify that the x2many
+ // value has changed
+ record._changes[name] = list.id;
+ list._changes = list._changes || [];
+
+ // save it in case of a [5] which will remove the _changes
+ var oldChanges = list._changes;
+ _.each(val, function (command) {
+ var rec, recID;
+ if (command[0] === 0 || command[0] === 1) {
+ // CREATE or UPDATE
+ if (command[0] === 0 && command[1]) {
+ // updating an existing (virtual) record
+ var previousChange = _.find(oldChanges, function (operation) {
+ var child = self.localData[operation.id];
+ return child && (child.ref === command[1]);
+ });
+ recID = previousChange && previousChange.id;
+ rec = self.localData[recID];
+ }
+ if (command[0] === 1 && command[1]) {
+ // updating an existing record
+ rec = self.localData[list._cache[command[1]]];
+ }
+ if (!rec) {
+ var params = {
+ context: list.context,
+ fields: list.fields,
+ fieldsInfo: list.fieldsInfo,
+ modelName: list.model,
+ parentID: list.id,
+ viewType: list.viewType,
+ ref: command[1],
+ };
+ if (command[0] === 1) {
+ params.res_id = command[1];
+ }
+ rec = self._makeDataPoint(params);
+ list._cache[rec.res_id] = rec.id;
+ if (options.firstOnChange) {
+ // this is necessary so the fields are initialized
+ rec.getFieldNames().forEach(fieldName => {
+ if (!(fieldName in rec.data)) {
+ rec.data[fieldName] = null;
+ }
+ });
+ }
+ }
+ // Do not abandon the record if it has been created
+ // from `default_get`. The list has a savepoint only
+ // after having fully executed `default_get`.
+ rec._noAbandon = !list._savePoint;
+ list._changes.push({operation: 'ADD', id: rec.id});
+ if (command[0] === 1) {
+ list._changes.push({operation: 'UPDATE', id: rec.id});
+ }
+ defs.push(self._applyOnChange(command[2], rec, {
+ firstOnChange: options.firstOnChange,
+ }));
+ } else if (command[0] === 4) {
+ // LINK TO
+ linkRecord(list, command[1]);
+ } else if (command[0] === 5) {
+ // DELETE ALL
+ list._changes = [{operation: 'REMOVE_ALL'}];
+ } else if (command[0] === 6) {
+ list._changes = [{operation: 'REMOVE_ALL'}];
+ _.each(command[2], function (resID) {
+ linkRecord(list, resID);
+ });
+ }
+ });
+ var def = self._readUngroupedList(list).then(function () {
+ var x2ManysDef = self._fetchX2ManysBatched(list);
+ var referencesDef = self._fetchReferencesBatched(list);
+ return Promise.all([x2ManysDef, referencesDef]);
+ });
+ defs.push(def);
+ } else {
+ var newValue = self._parseServerValue(field, val);
+ if (newValue !== oldValue) {
+ record._changes[name] = newValue;
+ }
+ }
+ }
+ return Promise.all(defs);
+
+ // inner function that adds a record (based on its res_id) to a list
+ // dataPoint (used for onchanges that return commands 4 (LINK TO) or
+ // commands 6 (REPLACE WITH))
+ function linkRecord (list, resID) {
+ rec = self.localData[list._cache[resID]];
+ if (rec) {
+ // modifications done on a record are discarded if the onchange
+ // uses a LINK TO or a REPLACE WITH
+ self.discardChanges(rec.id);
+ }
+ // the dataPoint id will be set when the record will be fetched (for
+ // now, this dataPoint may not exist yet)
+ list._changes.push({
+ operation: 'ADD',
+ id: rec ? rec.id : null,
+ resID: resID,
+ });
+ }
+ },
+ /**
+ * When an operation is applied to a x2many field, the field widgets
+ * generate one (or more) command, which describes the exact operation.
+ * This method tries to interpret these commands and apply them to the
+ * localData.
+ *
+ * @param {Object} record
+ * @param {string} fieldName
+ * @param {Object} command A command object. It should have a 'operation'
+ * key. For example, it looks like {operation: ADD, id: 'partner_1'}
+ * @param {Object} [options]
+ * @param {string} [options.viewType] current viewType. If not set, we will assume
+ * main viewType from the record
+ * @param {boolean} [options.allowWarning=false] if true, change
+ * operation can complete, even if a warning is raised
+ * (only supported by the 'CREATE' command.operation)
+ * @returns {Promise}
+ */
+ _applyX2ManyChange: async function (record, fieldName, command, options) {
+ if (command.operation === 'TRIGGER_ONCHANGE') {
+ // the purpose of this operation is to trigger an onchange RPC, so
+ // there is no need to apply any change on the record (the changes
+ // have probably been already applied and saved, usecase: many2many
+ // edition in a dialog)
+ return Promise.resolve();
+ }
+
+ var self = this;
+ var localID = (record._changes && record._changes[fieldName]) || record.data[fieldName];
+ var list = this.localData[localID];
+ var field = record.fields[fieldName];
+ var viewType = (options && options.viewType) || record.viewType;
+ var fieldInfo = record.fieldsInfo[viewType][fieldName];
+ var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
+ var def, rec;
+ var defs = [];
+ list._changes = list._changes || [];
+
+ switch (command.operation) {
+ case 'ADD':
+ // for now, we are in the context of a one2many field
+ // the command should look like this:
+ // { operation: 'ADD', id: localID }
+ // The corresponding record may contain value for fields that
+ // are unknown in the list (e.g. fields that are in the
+ // subrecord form view but not in the kanban or list view), so
+ // to ensure that onchanges are correctly handled, we extend the
+ // list's fields with those in the created record
+ var newRecord = this.localData[command.id];
+ _.defaults(list.fields, newRecord.fields);
+ _.defaults(list.fieldsInfo, newRecord.fieldsInfo);
+ newRecord.fields = list.fields;
+ newRecord.fieldsInfo = list.fieldsInfo;
+ newRecord.viewType = list.viewType;
+ list._cache[newRecord.res_id] = newRecord.id;
+ list._changes.push(command);
+ break;
+ case 'ADD_M2M':
+ // force to use link command instead of create command
+ list._forceM2MLink = true;
+ // handle multiple add: command[2] may be a dict of values (1
+ // record added) or an array of dict of values
+ var data = _.isArray(command.ids) ? command.ids : [command.ids];
+
+ // name_create records for which there is no id (typically, could
+ // be the case of a quick_create in a many2many_tags, so data.length
+ // is 1)
+ for (const r of data) {
+ if (!r.id && r.display_name) {
+ const prom = this._rpc({
+ model: field.relation,
+ method: 'name_create',
+ args: [r.display_name],
+ context: this._getContext(record, {fieldName: fieldName, viewType: options.viewType}),
+ }).then(result => {
+ r.id = result[0];
+ r.display_name = result[1];
+ });
+ defs.push(prom);
+ }
+ }
+ await Promise.all(defs);
+
+ // Ensure the local data repository (list) boundaries can handle incoming records (data)
+ if (data.length + list.res_ids.length > list.limit) {
+ list.limit = data.length + list.res_ids.length;
+ }
+
+ var list_records = {};
+ _.each(data, function (d) {
+ rec = self._makeDataPoint({
+ context: record.context,
+ modelName: field.relation,
+ fields: view ? view.fields : fieldInfo.relatedFields,
+ fieldsInfo: view ? view.fieldsInfo : fieldInfo.fieldsInfo,
+ res_id: d.id,
+ data: d,
+ viewType: view ? view.type : fieldInfo.viewType,
+ parentID: list.id,
+ });
+ list_records[d.id] = rec;
+ list._cache[rec.res_id] = rec.id;
+ list._changes.push({operation: 'ADD', id: rec.id});
+ });
+ // read list's records as we only have their ids and optionally their display_name
+ // (we can't use function readUngroupedList because those records are only in the
+ // _changes so this is a very specific case)
+ // this could be optimized by registering the fetched records in the list's _cache
+ // so that if a record is removed and then re-added, it won't be fetched twice
+ var fieldNames = list.getFieldNames();
+ if (fieldNames.length) {
+ def = this._rpc({
+ model: list.model,
+ method: 'read',
+ args: [_.pluck(data, 'id'), fieldNames],
+ context: _.extend({}, record.context, field.context),
+ }).then(function (records) {
+ _.each(records, function (record) {
+ list_records[record.id].data = record;
+ self._parseServerData(fieldNames, list, record);
+ });
+ return Promise.all([
+ self._fetchX2ManysBatched(list),
+ self._fetchReferencesBatched(list)
+ ]);
+ });
+ defs.push(def);
+ }
+ break;
+ case 'CREATE':
+ var createOptions = _.extend({
+ context: command.context,
+ position: command.position
+ }, options || {});
+ createOptions.viewType = fieldInfo.mode;
+
+ def = this._addX2ManyDefaultRecord(list, createOptions).then(function (ids) {
+ _.each(ids, function(id){
+ if (command.position === 'bottom' && list.orderedResIDs && list.orderedResIDs.length >= list.limit) {
+ list.tempLimitIncrement = (list.tempLimitIncrement || 0) + 1;
+ list.limit += 1;
+ }
+ // FIXME: hack for lunch widget, which does useless default_get and onchange
+ if (command.data) {
+ return self._applyChange(id, command.data);
+ }
+ });
+ });
+ defs.push(def);
+ break;
+ case 'UPDATE':
+ list._changes.push({operation: 'UPDATE', id: command.id});
+ if (command.data) {
+ defs.push(this._applyChange(command.id, command.data, {
+ viewType: view && view.type,
+ }));
+ }
+ break;
+ case 'FORGET':
+ // Unlink the record of list.
+ list._forceM2MUnlink = true;
+ case 'DELETE':
+ // filter out existing operations involving the current
+ // dataPoint, and add a 'DELETE' or 'FORGET' operation only if there is
+ // no 'ADD' operation for that dataPoint, as it would mean
+ // that the record wasn't in the relation yet
+ var idsToRemove = command.ids;
+ list._changes = _.reject(list._changes, function (change, index) {
+ var idInCommands = _.contains(command.ids, change.id);
+ if (idInCommands && change.operation === 'ADD') {
+ idsToRemove = _.without(idsToRemove, change.id);
+ }
+ return idInCommands;
+ });
+ _.each(idsToRemove, function (id) {
+ var operation = list._forceM2MUnlink ? 'FORGET': 'DELETE';
+ list._changes.push({operation: operation, id: id});
+ });
+ break;
+ case 'DELETE_ALL':
+ // first remove all pending 'ADD' operations
+ list._changes = _.reject(list._changes, function (change) {
+ return change.operation === 'ADD';
+ });
+
+ // then apply 'DELETE' on existing records
+ return this._applyX2ManyChange(record, fieldName, {
+ operation: 'DELETE',
+ ids: list.res_ids
+ }, options);
+ case 'REPLACE_WITH':
+ // this is certainly not optimal... and not sure that it is
+ // correct if some ids are added and some other are removed
+ list._changes = [];
+ var newIds = _.difference(command.ids, list.res_ids);
+ var removedIds = _.difference(list.res_ids, command.ids);
+ var addDef, removedDef, values;
+ if (newIds.length) {
+ values = _.map(newIds, function (id) {
+ return {id: id};
+ });
+ addDef = this._applyX2ManyChange(record, fieldName, {
+ operation: 'ADD_M2M',
+ ids: values
+ }, options);
+ }
+ if (removedIds.length) {
+ var listData = _.map(list.data, function (localId) {
+ return self.localData[localId];
+ });
+ removedDef = this._applyX2ManyChange(record, fieldName, {
+ operation: 'DELETE',
+ ids: _.map(removedIds, function (resID) {
+ if (resID in list._cache) {
+ return list._cache[resID];
+ }
+ return _.findWhere(listData, {res_id: resID}).id;
+ }),
+ }, options);
+ }
+ return Promise.all([addDef, removedDef]);
+ case 'MULTI':
+ // allows batching multiple operations
+ _.each(command.commands, function (innerCommand){
+ defs.push(self._applyX2ManyChange(
+ record,
+ fieldName,
+ innerCommand,
+ options
+ ));
+ });
+ break;
+ }
+
+ return Promise.all(defs).then(function () {
+ // ensure to fetch up to 'limit' records (may be useful if records of
+ // the current page have been removed)
+ return self._readUngroupedList(list).then(function () {
+ return self._fetchX2ManysBatched(list);
+ });
+ });
+ },
+ /**
+ * In dataPoints of type list for x2manys, the changes are stored as a list
+ * of operations (being of type 'ADD', 'DELETE', 'FORGET', UPDATE' or 'REMOVE_ALL').
+ * This function applies the operation of such a dataPoint without altering
+ * the original dataPoint. It returns a copy of the dataPoint in which the
+ * 'count', 'data' and 'res_ids' keys have been updated.
+ *
+ * @private
+ * @param {Object} dataPoint of type list
+ * @param {Object} [options] mostly contains the range of operations to apply
+ * @param {Object} [options.from=0] the index of the first operation to apply
+ * @param {Object} [options.to=length] the index of the last operation to apply
+ * @param {Object} [options.position] if set, each new operation will be set
+ * accordingly at the top or the bottom of the list
+ * @returns {Object} element of type list in which the commands have been
+ * applied
+ */
+ _applyX2ManyOperations: function (list, options) {
+ if (!list.static) {
+ // this function only applies on x2many lists
+ return list;
+ }
+ var self = this;
+ list = _.extend({}, list);
+ list.res_ids = list.res_ids.slice(0);
+ var changes = list._changes || [];
+ if (options) {
+ var to = options.to === 0 ? 0 : (options.to || changes.length);
+ changes = changes.slice(options.from || 0, to);
+ }
+ _.each(changes, function (change) {
+ var relRecord;
+ if (change.id) {
+ relRecord = self.localData[change.id];
+ }
+ switch (change.operation) {
+ case 'ADD':
+ list.count++;
+ var resID = relRecord ? relRecord.res_id : change.resID;
+ if (change.position === 'top' && (options ? options.position !== 'bottom' : true)) {
+ list.res_ids.unshift(resID);
+ } else {
+ list.res_ids.push(resID);
+ }
+ break;
+ case 'FORGET':
+ case 'DELETE':
+ list.count--;
+ // FIXME awa: there is no "relRecord" for o2m field
+ // seems like using change.id does the trick -> check with framework JS
+ var deletedResID = relRecord ? relRecord.res_id : change.id;
+ list.res_ids = _.without(list.res_ids, deletedResID);
+ break;
+ case 'REMOVE_ALL':
+ list.count = 0;
+ list.res_ids = [];
+ break;
+ case 'UPDATE':
+ // nothing to do for UPDATE commands
+ break;
+ }
+ });
+ this._setDataInRange(list);
+ return list;
+ },
+ /**
+ * Helper method to build a 'spec', that is a description of all fields in
+ * the view that have a onchange defined on them.
+ *
+ * An onchange spec is necessary as an argument to the /onchange route. It
+ * looks like this: { field: "1", anotherField: "", relation.subField: "1"}
+ *
+ * The first onchange call will fill up the record with default values, so
+ * we need to send every field name known to us in this case.
+ *
+ * @see _performOnChange
+ *
+ * @param {Object} record resource object of type 'record'
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
+ * @returns {Object} with two keys
+ * - 'hasOnchange': true iff there is at least a field with onchange
+ * - 'onchangeSpec': the onchange spec
+ */
+ _buildOnchangeSpecs: function (record, viewType) {
+ let hasOnchange = false;
+ const onchangeSpec = {};
+ var fieldsInfo = record.fieldsInfo[viewType || record.viewType];
+ generateSpecs(fieldsInfo, record.fields);
+
+ // recursively generates the onchange specs for fields in fieldsInfo,
+ // and their subviews
+ function generateSpecs(fieldsInfo, fields, prefix) {
+ prefix = prefix || '';
+ _.each(Object.keys(fieldsInfo), function (name) {
+ var field = fields[name];
+ var fieldInfo = fieldsInfo[name];
+ var key = prefix + name;
+ onchangeSpec[key] = (field.onChange) || "";
+ if (field.onChange) {
+ hasOnchange = true;
+ }
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ _.each(fieldInfo.views, function (view) {
+ generateSpecs(view.fieldsInfo[view.type], view.fields, key + '.');
+ });
+ }
+ });
+ }
+ return { hasOnchange, onchangeSpec };
+ },
+ /**
+ * Ensures that dataPoint ids are always synchronized between the main and
+ * sample models when being in sample mode. Here, we now that __id in the
+ * sample model is always greater than __id in the main model (as it
+ * contains strictly more datapoints).
+ *
+ * @override
+ */
+ async _callSampleModel() {
+ await this._super(...arguments);
+ if (this._isInSampleMode) {
+ this.__id = this.sampleModel.__id;
+ }
+ },
+ /**
+ * Compute the default value that the handle field should take.
+ * We need to compute this in order for new lines to be added at the correct position.
+ *
+ * @private
+ * @param {Object} listID
+ * @param {string} position
+ * @return {Object} empty object if no overrie has to be done, or:
+ * field: the name of the field to override,
+ * value: the value to use for that field
+ */
+ _computeOverrideDefaultFields: function (listID, position) {
+ var list = this.localData[listID];
+ var handleField;
+
+ // Here listID is actually just parentID, it's not yet confirmed
+ // to be a list.
+ // If we are not in the case that interests us,
+ // listID will be undefined and this check will work.
+ if (!list) {
+ return {};
+ }
+
+ position = position || 'bottom';
+
+ // Let's find if there is a field with handle.
+ if (!list.fieldsInfo) {
+ return {};
+ }
+ for (var field in list.fieldsInfo.list) {
+ if (list.fieldsInfo.list[field].widget === 'handle') {
+ handleField = field;
+ break;
+ // If there are 2 handle fields on the same list,
+ // we take the first one we find.
+ // And that will be alphabetically on the field name...
+ }
+ }
+
+ if (!handleField) {
+ return {};
+ }
+
+ // We don't want to override the default value
+ // if the list is not ordered by the handle field.
+ var isOrderedByHandle = list.orderedBy
+ && list.orderedBy.length
+ && list.orderedBy[0].asc === true
+ && list.orderedBy[0].name === handleField;
+
+ if (!isOrderedByHandle) {
+ return {};
+ }
+
+ // We compute the list (get) to apply the pending changes before doing our work,
+ // otherwise new lines might not be taken into account.
+ // We use raw: true because we only need to load the first level of relation.
+ var computedList = this.get(list.id, {raw: true});
+
+ // We don't need to worry about the position of a new line if the list is empty.
+ if (!computedList || !computedList.data || !computedList.data.length) {
+ return {};
+ }
+
+ // If there are less elements in the list than the limit of
+ // the page then take the index of the last existing line.
+
+ // If the button is at the top, we want the new element on
+ // the first line of the page.
+
+ // If the button is at the bottom, we want the new element
+ // after the last line of the page
+ // (= theorically it will be the first element of the next page).
+
+ // We ignore list.offset because computedList.data
+ // will only have the current page elements.
+
+ var index = Math.min(
+ computedList.data.length - 1,
+ position !== 'top' ? list.limit - 1 : 0
+ );
+
+ // This positioning will almost be correct. There might just be
+ // an issue if several other lines have the same handleFieldValue.
+
+ // TODO ideally: if there is an element with the same handleFieldValue,
+ // that one and all the following elements must be incremented
+ // by 1 (at least until there is a gap in the numbering).
+
+ // We don't do it now because it's not an important case.
+ // However, we can for sure increment by 1 if we are on the last page.
+ var handleFieldValue = computedList.data[index].data[handleField];
+ if (position === 'top') {
+ handleFieldValue--;
+ } else if (list.count <= list.offset + list.limit - (list.tempLimitIncrement || 0)) {
+ handleFieldValue++;
+ }
+ return {
+ field: handleField,
+ value: handleFieldValue,
+ };
+ },
+ /**
+ * Evaluate modifiers
+ *
+ * @private
+ * @param {Object} element a valid element object, which will serve as eval
+ * context.
+ * @param {Object} modifiers
+ * @returns {Object}
+ * @throws {Error} if one of the modifier domains is invalid
+ */
+ _evalModifiers: function (element, modifiers) {
+ let evalContext = null;
+ const evaluated = {};
+ for (const k of ['invisible', 'column_invisible', 'readonly', 'required']) {
+ const mod = modifiers[k];
+ if (mod === undefined || mod === false || mod === true) {
+ if (k in modifiers) {
+ evaluated[k] = !!mod;
+ }
+ continue;
+ }
+ try {
+ evalContext = evalContext || this._getEvalContext(element);
+ evaluated[k] = new Domain(mod, evalContext).compute(evalContext);
+ } catch (e) {
+ throw new Error(_.str.sprintf('for modifier "%s": %s', k, e.message));
+ }
+ }
+ return evaluated;
+ },
+ /**
+ * Fetch name_get for a record datapoint.
+ *
+ * @param {Object} dataPoint
+ * @returns {Promise}
+ */
+ _fetchNameGet: function (dataPoint) {
+ return this._rpc({
+ model: dataPoint.model,
+ method: 'name_get',
+ args: [dataPoint.res_id],
+ context: dataPoint.getContext(),
+ }).then(function (result) {
+ dataPoint.data.display_name = result[0][1];
+ });
+ },
+ /**
+ * Fetch name_get for a field of type Many2one or Reference
+ *
+ * @private
+ * @params {Object} list: must be a datapoint of type list
+ * (for example: a datapoint representing a x2many)
+ * @params {string} fieldName: the name of a field of type Many2one or Reference
+ * @returns {Promise}
+ */
+ _fetchNameGets: function (list, fieldName) {
+ var self = this;
+ // We first get the model this way because if list.data is empty
+ // the _.each below will not make it.
+ var model = list.fields[fieldName].relation;
+ var records = [];
+ var ids = [];
+ list = this._applyX2ManyOperations(list);
+
+ _.each(list.data, function (localId) {
+ var record = self.localData[localId];
+ var data = record._changes || record.data;
+ var many2oneId = data[fieldName];
+ if (!many2oneId) { return; }
+ var many2oneRecord = self.localData[many2oneId];
+ records.push(many2oneRecord);
+ ids.push(many2oneRecord.res_id);
+ // We need to calculate the model this way too because
+ // field .relation is not set for a reference field.
+ model = many2oneRecord.model;
+ });
+
+ if (!ids.length) {
+ return Promise.resolve();
+ }
+ return this._rpc({
+ model: model,
+ method: 'name_get',
+ args: [_.uniq(ids)],
+ context: list.context,
+ })
+ .then(function (name_gets) {
+ _.each(records, function (record) {
+ var nameGet = _.find(name_gets, function (nameGet) {
+ return nameGet[0] === record.data.id;
+ });
+ record.data.display_name = nameGet[1];
+ });
+ });
+ },
+ /**
+ * For a given resource of type 'record', fetch all data.
+ *
+ * @param {Object} record local resource
+ * @param {Object} [options]
+ * @param {string[]} [options.fieldNames] the list of fields to fetch. If
+ * not given, fetch all the fields in record.fieldNames (+ display_name)
+ * @param {string} [options.viewType] the type of view for which the record
+ * is fetched (usefull to load the adequate fields), by defaults, uses
+ * record.viewType
+ * @returns {Promise<Object>} resolves to the record or is rejected in
+ * case no id given were valid ids
+ */
+ _fetchRecord: function (record, options) {
+ var self = this;
+ options = options || {};
+ var fieldNames = options.fieldNames || record.getFieldNames(options);
+ fieldNames = _.uniq(fieldNames.concat(['display_name']));
+ return this._rpc({
+ model: record.model,
+ method: 'read',
+ args: [[record.res_id], fieldNames],
+ context: _.extend({bin_size: true}, record.getContext()),
+ })
+ .then(function (result) {
+ if (result.length === 0) {
+ return Promise.reject();
+ }
+ result = result[0];
+ record.data = _.extend({}, record.data, result);
+ })
+ .then(function () {
+ self._parseServerData(fieldNames, record, record.data);
+ })
+ .then(function () {
+ return Promise.all([
+ self._fetchX2Manys(record, options),
+ self._fetchReferences(record, options)
+ ]).then(function () {
+ return self._postprocess(record, options);
+ });
+ });
+ },
+ /**
+ * Fetch the `name_get` for a reference field.
+ *
+ * @private
+ * @param {Object} record
+ * @param {string} fieldName
+ * @returns {Promise}
+ */
+ _fetchReference: function (record, fieldName) {
+ var self = this;
+ var def;
+ var value = record._changes && record._changes[fieldName] || record.data[fieldName];
+ var model = value && value.split(',')[0];
+ var resID = value && parseInt(value.split(',')[1]);
+ if (model && model !== 'False' && resID) {
+ def = self._rpc({
+ model: model,
+ method: 'name_get',
+ args: [resID],
+ context: record.getContext({fieldName: fieldName}),
+ }).then(function (result) {
+ return self._makeDataPoint({
+ data: {
+ id: result[0][0],
+ display_name: result[0][1],
+ },
+ modelName: model,
+ parentID: record.id,
+ });
+ });
+ }
+ return Promise.resolve(def);
+ },
+ /**
+ * Fetches data for reference fields and assigns these data to newly
+ * created datapoint.
+ * Then places datapoint reference into parent record.
+ *
+ * @param {Object} datapoints a collection of ids classed by model,
+ * @see _getDataToFetchByModel
+ * @param {string} model
+ * @param {string} fieldName
+ * @returns {Promise}
+ */
+ _fetchReferenceData: function (datapoints, model, fieldName) {
+ var self = this;
+ var ids = _.map(Object.keys(datapoints), function (id) { return parseInt(id); });
+ // we need one parent for the context (they all have the same)
+ var parent = datapoints[ids[0]][0];
+ var def = self._rpc({
+ model: model,
+ method: 'name_get',
+ args: [ids],
+ context: self.localData[parent].getContext({fieldName: fieldName}),
+ }).then(function (result) {
+ _.each(result, function (el) {
+ var parentIDs = datapoints[el[0]];
+ _.each(parentIDs, function (parentID) {
+ var parent = self.localData[parentID];
+ var referenceDp = self._makeDataPoint({
+ data: {
+ id: el[0],
+ display_name: el[1],
+ },
+ modelName: model,
+ parentID: parent.id,
+ });
+ parent.data[fieldName] = referenceDp.id;
+ });
+ });
+ });
+ return def;
+ },
+ /**
+ * Fetch the extra data (`name_get`) for the reference fields of the record
+ * model.
+ *
+ * @private
+ * @param {Object} record
+ * @returns {Promise}
+ */
+ _fetchReferences: function (record, options) {
+ var self = this;
+ var defs = [];
+ var fieldNames = options && options.fieldNames || record.getFieldNames();
+ _.each(fieldNames, function (fieldName) {
+ var field = record.fields[fieldName];
+ if (field.type === 'reference') {
+ var def = self._fetchReference(record, fieldName).then(function (dataPoint) {
+ if (dataPoint) {
+ record.data[fieldName] = dataPoint.id;
+ }
+ });
+ defs.push(def);
+ }
+ });
+ return Promise.all(defs);
+ },
+ /**
+ * Batch requests for one reference field in list (one request by different
+ * model in the field values).
+ *
+ * @see _fetchReferencesBatched
+ * @param {Object} list
+ * @param {string} fieldName
+ * @returns {Promise}
+ */
+ _fetchReferenceBatched: function (list, fieldName) {
+ var self = this;
+ list = this._applyX2ManyOperations(list);
+ this._sortList(list);
+
+ var toFetch = this._getDataToFetchByModel(list, fieldName);
+ var defs = [];
+ // one name_get by model
+ _.each(toFetch, function (datapoints, model) {
+ defs.push(self._fetchReferenceData(datapoints, model, fieldName));
+ });
+
+ return Promise.all(defs);
+ },
+ /**
+ * Batch requests for references for datapoint of type list.
+ *
+ * @param {Object} list
+ * @returns {Promise}
+ */
+ _fetchReferencesBatched: function (list) {
+ var defs = [];
+ var fieldNames = list.getFieldNames();
+ for (var i = 0; i < fieldNames.length; i++) {
+ var field = list.fields[fieldNames[i]];
+ if (field.type === 'reference') {
+ defs.push(this._fetchReferenceBatched(list, fieldNames[i]));
+ }
+ }
+ return Promise.all(defs);
+ },
+ /**
+ * Batch reference requests for all records in list.
+ *
+ * @see _fetchReferencesSingleBatch
+ * @param {Object} list a valid resource object
+ * @param {string} fieldName
+ * @returns {Promise}
+ */
+ _fetchReferenceSingleBatch: function (list, fieldName) {
+ var self = this;
+
+ // collect ids by model
+ var toFetch = {};
+ _.each(list.data, function (groupIndex) {
+ var group = self.localData[groupIndex];
+ self._getDataToFetchByModel(group, fieldName, toFetch);
+ });
+
+ var defs = [];
+ // one name_get by model
+ _.each(toFetch, function (datapoints, model) {
+ defs.push(self._fetchReferenceData(datapoints, model, fieldName));
+ });
+
+ return Promise.all(defs);
+ },
+ /**
+ * Batch requests for all reference field in list's children.
+ * Called by _readGroup to make only one 'name_get' rpc by fieldName.
+ *
+ * @param {Object} list a valid resource object
+ * @returns {Promise}
+ */
+ _fetchReferencesSingleBatch: function (list) {
+ var defs = [];
+ var fieldNames = list.getFieldNames();
+ for (var fIndex in fieldNames) {
+ var field = list.fields[fieldNames[fIndex]];
+ if (field.type === 'reference') {
+ defs.push(this._fetchReferenceSingleBatch(list, fieldNames[fIndex]));
+ }
+ }
+ return Promise.all(defs);
+ },
+ /**
+ * Fetch model data from server, relationally to fieldName and resulted
+ * field relation. For example, if fieldName is "tag_ids" and referred to
+ * project.tags, it will fetch project.tags' related fields where its id is
+ * contained in toFetch.ids array.
+ *
+ * @param {Object} list a valid resource object
+ * @param {Object} toFetch a list of records and res_ids,
+ * @see _getDataToFetch
+ * @param {string} fieldName
+ * @returns {Promise}
+ */
+ _fetchRelatedData: function (list, toFetch, fieldName) {
+ var self = this;
+ var ids = _.keys(toFetch);
+ for (var i = 0; i < ids.length; i++) {
+ ids[i] = Number(ids[i]);
+ }
+ var fieldInfo = list.fieldsInfo[list.viewType][fieldName];
+
+ if (!ids.length || fieldInfo.__no_fetch) {
+ return Promise.resolve();
+ }
+
+ var def;
+ var fieldNames = _.keys(fieldInfo.relatedFields);
+ if (fieldNames.length) {
+ var field = list.fields[fieldName];
+ def = this._rpc({
+ model: field.relation,
+ method: 'read',
+ args: [ids, fieldNames],
+ context: list.getContext() || {},
+ });
+ } else {
+ def = Promise.resolve(_.map(ids, function (id) {
+ return {id:id};
+ }));
+ }
+ return def.then(function (result) {
+ var records = _.uniq(_.flatten(_.values(toFetch)));
+ self._updateRecordsData(records, fieldName, result);
+ });
+ },
+ /**
+ * Check the AbstractField specializations that are (will be) used by the
+ * given record and fetch the special data they will need. Special data are
+ * data that the rendering of the record won't need if it was not using
+ * particular widgets (example of these can be found at the methods which
+ * start with _fetchSpecial).
+ *
+ * @param {Object} record - an element from the localData
+ * @param {Object} options
+ * @returns {Promise<Array>}
+ * The promise is resolved with an array containing the names of
+ * the field whose special data has been changed.
+ */
+ _fetchSpecialData: function (record, options) {
+ var self = this;
+ var specialFieldNames = [];
+ var fieldNames = (options && options.fieldNames) || record.getFieldNames();
+ return Promise.all(_.map(fieldNames, function (name) {
+ var viewType = (options && options.viewType) || record.viewType;
+ var fieldInfo = record.fieldsInfo[viewType][name] || {};
+ var Widget = fieldInfo.Widget;
+ if (Widget && Widget.prototype.specialData) {
+ return self[Widget.prototype.specialData](record, name, fieldInfo).then(function (data) {
+ if (data === undefined) {
+ return;
+ }
+ record.specialData[name] = data;
+ specialFieldNames.push(name);
+ });
+ }
+ })).then(function () {
+ return specialFieldNames;
+ });
+ },
+ /**
+ * Fetches all the m2o records associated to the given fieldName. If the
+ * given fieldName is not a m2o field, nothing is done.
+ *
+ * @param {Object} record - an element from the localData
+ * @param {Object} fieldName - the name of the field
+ * @param {Object} fieldInfo
+ * @param {string[]} [fieldsToRead] - the m2os fields to read (id and
+ * display_name are automatic).
+ * @returns {Promise<any>}
+ * The promise is resolved with the fetched special data. If this
+ * data is the same as the previously fetched one (for the given
+ * parameters), no RPC is done and the promise is resolved with
+ * the undefined value.
+ */
+ _fetchSpecialMany2ones: function (record, fieldName, fieldInfo, fieldsToRead) {
+ var field = record.fields[fieldName];
+ if (field.type !== "many2one") {
+ return Promise.resolve();
+ }
+
+ var context = record.getContext({fieldName: fieldName});
+ var domain = record.getDomain({fieldName: fieldName});
+ if (domain.length) {
+ var localID = (record._changes && fieldName in record._changes) ?
+ record._changes[fieldName] :
+ record.data[fieldName];
+ if (localID) {
+ var element = this.localData[localID];
+ domain = ["|", ["id", "=", element.data.id]].concat(domain);
+ }
+ }
+
+ // avoid rpc if not necessary
+ var hasChanged = this._saveSpecialDataCache(record, fieldName, {
+ context: context,
+ domain: domain,
+ });
+ if (!hasChanged) {
+ return Promise.resolve();
+ }
+
+ var self = this;
+ return this._rpc({
+ model: field.relation,
+ method: 'search_read',
+ fields: ["id"].concat(fieldsToRead || []),
+ context: context,
+ domain: domain,
+ })
+ .then(function (records) {
+ var ids = _.pluck(records, 'id');
+ return self._rpc({
+ model: field.relation,
+ method: 'name_get',
+ args: [ids],
+ context: context,
+ })
+ .then(function (name_gets) {
+ _.each(records, function (rec) {
+ var name_get = _.find(name_gets, function (n) {
+ return n[0] === rec.id;
+ });
+ rec.display_name = name_get[1];
+ });
+ return records;
+ });
+ });
+ },
+ /**
+ * Fetches all the relation records associated to the given fieldName. If
+ * the given fieldName is not a relational field, nothing is done.
+ *
+ * @param {Object} record - an element from the localData
+ * @param {Object} fieldName - the name of the field
+ * @returns {Promise<any>}
+ * The promise is resolved with the fetched special data. If this
+ * data is the same as the previously fetched one (for the given
+ * parameters), no RPC is done and the promise is resolved with
+ * the undefined value.
+ */
+ _fetchSpecialRelation: function (record, fieldName) {
+ var field = record.fields[fieldName];
+ if (!_.contains(["many2one", "many2many", "one2many"], field.type)) {
+ return Promise.resolve();
+ }
+
+ var context = record.getContext({fieldName: fieldName});
+ var domain = record.getDomain({fieldName: fieldName});
+
+ // avoid rpc if not necessary
+ var hasChanged = this._saveSpecialDataCache(record, fieldName, {
+ context: context,
+ domain: domain,
+ });
+ if (!hasChanged) {
+ return Promise.resolve();
+ }
+
+ return this._rpc({
+ model: field.relation,
+ method: 'name_search',
+ args: ["", domain],
+ context: context
+ });
+ },
+ /**
+ * Fetches the `name_get` associated to the reference widget if the field is
+ * a `char` (which is a supported case).
+ *
+ * @private
+ * @param {Object} record - an element from the localData
+ * @param {Object} fieldName - the name of the field
+ * @returns {Promise}
+ */
+ _fetchSpecialReference: function (record, fieldName) {
+ var def;
+ var field = record.fields[fieldName];
+ if (field.type === 'char') {
+ // if the widget reference is set on a char field, the name_get
+ // needs to be fetched a posteriori
+ def = this._fetchReference(record, fieldName);
+ }
+ return Promise.resolve(def);
+ },
+ /**
+ * Fetches all the m2o records associated to the given fieldName. If the
+ * given fieldName is not a m2o field, nothing is done. The difference with
+ * _fetchSpecialMany2ones is that the field given by options.fold_field is
+ * also fetched.
+ *
+ * @param {Object} record - an element from the localData
+ * @param {Object} fieldName - the name of the field
+ * @param {Object} fieldInfo
+ * @returns {Promise<any>}
+ * The promise is resolved with the fetched special data. If this
+ * data is the same as the previously fetched one (for the given
+ * parameters), no RPC is done and the promise is resolved with
+ * the undefined value.
+ */
+ _fetchSpecialStatus: function (record, fieldName, fieldInfo) {
+ var foldField = fieldInfo.options.fold_field;
+ var fieldsToRead = foldField ? [foldField] : [];
+ return this._fetchSpecialMany2ones(record, fieldName, fieldInfo, fieldsToRead).then(function (m2os) {
+ _.each(m2os, function (m2o) {
+ m2o.fold = foldField ? m2o[foldField] : false;
+ });
+ return m2os;
+ });
+ },
+ /**
+ * Fetches the number of records associated to the domain the value of the
+ * given field represents.
+ *
+ * @param {Object} record - an element from the localData
+ * @param {Object} fieldName - the name of the field
+ * @param {Object} fieldInfo
+ * @returns {Promise<any>}
+ * The promise is resolved with the fetched special data. If this
+ * data is the same as the previously fetched one (for the given
+ * parameters), no RPC is done and the promise is resolved with
+ * the undefined value.
+ */
+ _fetchSpecialDomain: function (record, fieldName, fieldInfo) {
+ var self = this;
+ var context = record.getContext({fieldName: fieldName});
+
+ var domainModel = fieldInfo.options.model;
+ if (record.data.hasOwnProperty(domainModel)) {
+ domainModel = record._changes && record._changes[domainModel] || record.data[domainModel];
+ }
+ var domainValue = record._changes && record._changes[fieldName] || record.data[fieldName] || [];
+
+ // avoid rpc if not necessary
+ var hasChanged = this._saveSpecialDataCache(record, fieldName, {
+ context: context,
+ domainModel: domainModel,
+ domainValue: domainValue,
+ });
+ if (!hasChanged) {
+ return Promise.resolve();
+ } else if (!domainModel) {
+ return Promise.resolve({
+ model: domainModel,
+ nbRecords: 0,
+ });
+ }
+
+ return new Promise(function (resolve) {
+ var evalContext = self._getEvalContext(record);
+ self._rpc({
+ model: domainModel,
+ method: 'search_count',
+ args: [Domain.prototype.stringToArray(domainValue, evalContext)],
+ context: context
+ })
+ .then(function (nbRecords) {
+ resolve({
+ model: domainModel,
+ nbRecords: nbRecords,
+ });
+ })
+ .guardedCatch(function (reason) {
+ var e = reason.event;
+ e.preventDefault(); // prevent traceback (the search_count might be intended to break)
+ resolve({
+ model: domainModel,
+ nbRecords: 0,
+ });
+ });
+ });
+ },
+ /**
+ * Fetch all data in a ungrouped list
+ *
+ * @param {Object} list a valid resource object
+ * @param {Object} [options]
+ * @param {boolean} [options.enableRelationalFetch=true] if false, will not
+ * fetch x2m and relational data (that will be done by _readGroup in this
+ * case).
+ * @returns {Promise<Object>} resolves to the fecthed list
+ */
+ _fetchUngroupedList: function (list, options) {
+ options = _.defaults(options || {}, {enableRelationalFetch: true});
+ var self = this;
+ var def;
+ if (list.static) {
+ def = this._readUngroupedList(list).then(function () {
+ if (list.parentID && self.isNew(list.parentID)) {
+ // list from a default_get, so fetch display_name for many2one fields
+ var many2ones = self._getMany2OneFieldNames(list);
+ var defs = _.map(many2ones, function (name) {
+ return self._fetchNameGets(list, name);
+ });
+ return Promise.all(defs);
+ }
+ });
+ } else {
+ def = this._searchReadUngroupedList(list);
+ }
+ return def.then(function () {
+ if (options.enableRelationalFetch) {
+ return Promise.all([
+ self._fetchX2ManysBatched(list),
+ self._fetchReferencesBatched(list)
+ ]);
+ }
+ }).then(function () {
+ return list;
+ });
+ },
+ /**
+ * batch requests for 1 x2m in list
+ *
+ * @see _fetchX2ManysBatched
+ * @param {Object} list
+ * @param {string} fieldName
+ * @returns {Promise}
+ */
+ _fetchX2ManyBatched: function (list, fieldName) {
+ list = this._applyX2ManyOperations(list);
+ this._sortList(list);
+
+ var toFetch = this._getDataToFetch(list, fieldName);
+ return this._fetchRelatedData(list, toFetch, fieldName);
+ },
+ /**
+ * X2Manys have to be fetched by separate rpcs (their data are stored on
+ * different models). This method takes a record, look at its x2many fields,
+ * then, if necessary, create a local resource and fetch the corresponding
+ * data.
+ *
+ * It also tries to reuse data, if it can find an existing list, to prevent
+ * useless rpcs.
+ *
+ * @param {Object} record local resource
+ * @param {Object} [options]
+ * @param {string[]} [options.fieldNames] the list of fields to fetch.
+ * If not given, fetch all the fields in record.fieldNames
+ * @param {string} [options.viewType] the type of view for which the main
+ * record is fetched (useful to load the adequate fields), by defaults,
+ * uses record.viewType
+ * @returns {Promise}
+ */
+ _fetchX2Manys: function (record, options) {
+ var self = this;
+ var defs = [];
+ options = options || {};
+ var fieldNames = options.fieldNames || record.getFieldNames(options);
+ var viewType = options.viewType || record.viewType;
+ _.each(fieldNames, function (fieldName) {
+ var field = record.fields[fieldName];
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ var fieldInfo = record.fieldsInfo[viewType][fieldName];
+ var rawContext = fieldInfo && fieldInfo.context;
+ var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
+ var fieldsInfo = view ? view.fieldsInfo : (fieldInfo.fieldsInfo || {});
+ var ids = record.data[fieldName] || [];
+ var list = self._makeDataPoint({
+ count: ids.length,
+ context: _.extend({}, record.context, field.context),
+ fieldsInfo: fieldsInfo,
+ fields: view ? view.fields : fieldInfo.relatedFields,
+ limit: fieldInfo.limit,
+ modelName: field.relation,
+ res_ids: ids,
+ static: true,
+ type: 'list',
+ orderedBy: fieldInfo.orderedBy,
+ parentID: record.id,
+ rawContext: rawContext,
+ relationField: field.relation_field,
+ viewType: view ? view.type : fieldInfo.viewType,
+ });
+ record.data[fieldName] = list.id;
+ if (!fieldInfo.__no_fetch) {
+ var def = self._readUngroupedList(list).then(function () {
+ return Promise.all([
+ self._fetchX2ManysBatched(list),
+ self._fetchReferencesBatched(list)
+ ]);
+ });
+ defs.push(def);
+ }
+ }
+ });
+ return Promise.all(defs);
+ },
+ /**
+ * batch request for x2ms for datapoint of type list
+ *
+ * @param {Object} list
+ * @returns {Promise}
+ */
+ _fetchX2ManysBatched: function (list) {
+ var defs = [];
+ var fieldNames = list.getFieldNames();
+ for (var i = 0; i < fieldNames.length; i++) {
+ var field = list.fields[fieldNames[i]];
+ if (field.type === 'many2many' || field.type === 'one2many') {
+ defs.push(this._fetchX2ManyBatched(list, fieldNames[i]));
+ }
+ }
+ return Promise.all(defs);
+ },
+ /**
+ * For a non-static list, batches requests for all its sublists' records.
+ * Make only one rpc for all records on the concerned field.
+ *
+ * @see _fetchX2ManysSingleBatch
+ * @param {Object} list a valid resource object, its data must be another
+ * list containing records
+ * @param {string} fieldName
+ * @returns {Promise}
+ */
+ _fetchX2ManySingleBatch: function (list, fieldName) {
+ var self = this;
+ var toFetch = {};
+ _.each(list.data, function (groupIndex) {
+ var group = self.localData[groupIndex];
+ var nextDataToFetch = self._getDataToFetch(group, fieldName);
+ _.each(_.keys(nextDataToFetch), function (id) {
+ if (toFetch[id]) {
+ toFetch[id] = toFetch[id].concat(nextDataToFetch[id]);
+ } else {
+ toFetch[id] = nextDataToFetch[id];
+ }
+ });
+ });
+ return self._fetchRelatedData(list, toFetch, fieldName);
+ },
+ /**
+ * Batch requests for all x2m in list's children.
+ * Called by _readGroup to make only one 'read' rpc by fieldName.
+ *
+ * @param {Object} list a valid resource object
+ * @returns {Promise}
+ */
+ _fetchX2ManysSingleBatch: function (list) {
+ var defs = [];
+ var fieldNames = list.getFieldNames();
+ for (var i = 0; i < fieldNames.length; i++) {
+ var field = list.fields[fieldNames[i]];
+ if (field.type === 'many2many' || field.type === 'one2many'){
+ defs.push(this._fetchX2ManySingleBatch(list, fieldNames[i]));
+ }
+ }
+ return Promise.all(defs);
+ },
+ /**
+ * Generates an object mapping field names to their changed value in a given
+ * record (i.e. maps to the new value for basic fields, to the res_id for
+ * many2ones and to commands for x2manys).
+ *
+ * @private
+ * @param {Object} record
+ * @param {Object} [options]
+ * @param {boolean} [options.changesOnly=true] if true, only generates
+ * commands for fields that have changed (concerns x2many fields only)
+ * @param {boolean} [options.withReadonly=false] if false, doesn't generate
+ * changes for readonly fields
+ * @param {string} [options.viewType] current viewType. If not set, we will
+ * assume main viewType from the record. Note that if an editionViewType is
+ * specified for a field, it will take the priority over the viewType arg.
+ * @returns {Object} a map from changed fields to their new value
+ */
+ _generateChanges: function (record, options) {
+ options = options || {};
+ var viewType = options.viewType || record.viewType;
+ var changes;
+ const changesOnly = 'changesOnly' in options ? !!options.changesOnly : true;
+ if (!changesOnly) {
+ changes = _.extend({}, record.data, record._changes);
+ } else {
+ changes = _.extend({}, record._changes);
+ }
+ var withReadonly = options.withReadonly || false;
+ var commands = this._generateX2ManyCommands(record, {
+ changesOnly: changesOnly,
+ withReadonly: withReadonly,
+ });
+ for (var fieldName in record.fields) {
+ // remove readonly fields from the list of changes
+ if (!withReadonly && fieldName in changes || fieldName in commands) {
+ var editionViewType = record._editionViewType[fieldName] || viewType;
+ if (this._isFieldProtected(record, fieldName, editionViewType)) {
+ delete changes[fieldName];
+ continue;
+ }
+ }
+
+ // process relational fields and handle the null case
+ var type = record.fields[fieldName].type;
+ var value;
+ if (type === 'one2many' || type === 'many2many') {
+ if (!changesOnly || (commands[fieldName] && commands[fieldName].length)) { // replace localId by commands
+ changes[fieldName] = commands[fieldName];
+ } else { // no command -> no change for that field
+ delete changes[fieldName];
+ }
+ } else if (type === 'many2one' && fieldName in changes) {
+ value = changes[fieldName];
+ changes[fieldName] = value ? this.localData[value].res_id : false;
+ } else if (type === 'reference' && fieldName in changes) {
+ value = changes[fieldName];
+ changes[fieldName] = value ?
+ this.localData[value].model + ',' + this.localData[value].res_id :
+ false;
+ } else if (type === 'char' && changes[fieldName] === '') {
+ changes[fieldName] = false;
+ } else if (changes[fieldName] === null) {
+ changes[fieldName] = false;
+ }
+ }
+ return changes;
+ },
+ /**
+ * Generates an object mapping field names to their current value in a given
+ * record. If the record is inside a one2many, the returned object contains
+ * an additional key (the corresponding many2one field name) mapping to the
+ * current value of the parent record.
+ *
+ * @param {Object} record
+ * @param {Object} [options] This option object will be given to the private
+ * method _generateX2ManyCommands. In particular, it is useful to be able
+ * to send changesOnly:true to get all data, not only the current changes.
+ * @returns {Object} the data
+ */
+ _generateOnChangeData: function (record, options) {
+ options = _.extend({}, options || {}, {withReadonly: true});
+ var data = {};
+ if (!options.firstOnChange) {
+ var commands = this._generateX2ManyCommands(record, options);
+ data = _.extend(this.get(record.id, {raw: true}).data, commands);
+ // 'display_name' is automatically added to the list of fields to fetch,
+ // when fetching a record, even if it doesn't appear in the view. However,
+ // only the fields in the view must be passed to the onchange RPC, so we
+ // remove it from the data sent by RPC if it isn't in the view.
+ var hasDisplayName = _.some(record.fieldsInfo, function (fieldsInfo) {
+ return 'display_name' in fieldsInfo;
+ });
+ if (!hasDisplayName) {
+ delete data.display_name;
+ }
+ }
+
+ // one2many records have a parentID
+ if (record.parentID) {
+ var parent = this.localData[record.parentID];
+ // parent is the list element containing all the records in the
+ // one2many and parent.parentID is the ID of the main record
+ // if there is a relation field, this means that record is an elem
+ // in a one2many. The relation field is the corresponding many2one
+ if (parent.parentID && parent.relationField) {
+ var parentRecord = this.localData[parent.parentID];
+ data[parent.relationField] = this._generateOnChangeData(parentRecord);
+ }
+ }
+
+ return data;
+ },
+ /**
+ * Read all x2many fields and generate the commands for the server to create
+ * or write them...
+ *
+ * @param {Object} record
+ * @param {Object} [options]
+ * @param {string} [options.fieldNames] if given, generates the commands for
+ * these fields only
+ * @param {boolean} [changesOnly=false] if true, only generates commands for
+ * fields that have changed
+ * @param {boolean} [options.withReadonly=false] if false, doesn't generate
+ * changes for readonly fields in commands
+ * @returns {Object} a map from some field names to commands
+ */
+ _generateX2ManyCommands: function (record, options) {
+ var self = this;
+ options = options || {};
+ var fields = record.fields;
+ if (options.fieldNames) {
+ fields = _.pick(fields, options.fieldNames);
+ }
+ var commands = {};
+ var data = _.extend({}, record.data, record._changes);
+ var type;
+ for (var fieldName in fields) {
+ type = fields[fieldName].type;
+
+ if (type === 'many2many' || type === 'one2many') {
+ if (!data[fieldName]) {
+ // skip if this field is empty
+ continue;
+ }
+ commands[fieldName] = [];
+ var list = this.localData[data[fieldName]];
+ if (options.changesOnly && (!list._changes || !list._changes.length)) {
+ // if only changes are requested, skip if there is no change
+ continue;
+ }
+ var oldResIDs = list.res_ids.slice(0);
+ var relRecordAdded = [];
+ var relRecordUpdated = [];
+ _.each(list._changes, function (change) {
+ if (change.operation === 'ADD' && change.id) {
+ relRecordAdded.push(self.localData[change.id]);
+ } else if (change.operation === 'UPDATE' && !self.isNew(change.id)) {
+ // ignore new records that would have been updated
+ // afterwards, as all their changes would already
+ // be aggregated in the CREATE command
+ relRecordUpdated.push(self.localData[change.id]);
+ }
+ });
+ list = this._applyX2ManyOperations(list);
+ this._sortList(list);
+ if (type === 'many2many' || list._forceM2MLink) {
+ var relRecordCreated = _.filter(relRecordAdded, function (rec) {
+ return typeof rec.res_id === 'string';
+ });
+ var realIDs = _.difference(list.res_ids, _.pluck(relRecordCreated, 'res_id'));
+ // deliberately generate a single 'replace' command instead
+ // of a 'delete' and a 'link' commands with the exact diff
+ // because 1) performance-wise it doesn't change anything
+ // and 2) to guard against concurrent updates (policy: force
+ // a complete override of the actual value of the m2m)
+ commands[fieldName].push(x2ManyCommands.replace_with(realIDs));
+ _.each(relRecordCreated, function (relRecord) {
+ var changes = self._generateChanges(relRecord, options);
+ commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes));
+ });
+ // generate update commands for records that have been
+ // updated (it may happen with editable lists)
+ _.each(relRecordUpdated, function (relRecord) {
+ var changes = self._generateChanges(relRecord, options);
+ if (!_.isEmpty(changes)) {
+ var command = x2ManyCommands.update(relRecord.res_id, changes);
+ commands[fieldName].push(command);
+ }
+ });
+ } else if (type === 'one2many') {
+ var removedIds = _.difference(oldResIDs, list.res_ids);
+ var addedIds = _.difference(list.res_ids, oldResIDs);
+ var keptIds = _.intersection(oldResIDs, list.res_ids);
+
+ // the didChange variable keeps track of the fact that at
+ // least one id was updated
+ var didChange = false;
+ var changes, command, relRecord;
+ for (var i = 0; i < list.res_ids.length; i++) {
+ if (_.contains(keptIds, list.res_ids[i])) {
+ // this is an id that already existed
+ relRecord = _.findWhere(relRecordUpdated, {res_id: list.res_ids[i]});
+ changes = relRecord ? this._generateChanges(relRecord, options) : {};
+ if (!_.isEmpty(changes)) {
+ command = x2ManyCommands.update(relRecord.res_id, changes);
+ didChange = true;
+ } else {
+ command = x2ManyCommands.link_to(list.res_ids[i]);
+ }
+ commands[fieldName].push(command);
+ } else if (_.contains(addedIds, list.res_ids[i])) {
+ // this is a new id (maybe existing in DB, but new in JS)
+ relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]});
+ if (!relRecord) {
+ commands[fieldName].push(x2ManyCommands.link_to(list.res_ids[i]));
+ continue;
+ }
+ changes = this._generateChanges(relRecord, options);
+ if (!this.isNew(relRecord.id)) {
+ // the subrecord already exists in db
+ commands[fieldName].push(x2ManyCommands.link_to(relRecord.res_id));
+ if (this.isDirty(relRecord.id)) {
+ delete changes.id;
+ commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, changes));
+ }
+ } else {
+ // the subrecord is new, so create it
+
+ // we may have received values from an onchange for fields that are
+ // not in the view, and that we don't even know, as we don't have the
+ // fields_get of models of related fields. We save those values
+ // anyway, but for many2ones, we have to extract the id from the pair
+ // [id, display_name]
+ const rawChangesEntries = Object.entries(relRecord._rawChanges);
+ for (const [fieldName, value] of rawChangesEntries) {
+ const isMany2OneValue = Array.isArray(value) &&
+ value.length === 2 &&
+ Number.isInteger(value[0]) &&
+ typeof value[1] === 'string';
+ changes[fieldName] = isMany2OneValue ? value[0] : value;
+ }
+
+ commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes));
+ }
+ }
+ }
+ if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) {
+ // in this situation, we have no changed ids, no added
+ // ids and no removed ids, so we can safely ignore the
+ // last changes
+ commands[fieldName] = [];
+ }
+ // add delete commands
+ for (i = 0; i < removedIds.length; i++) {
+ if (list._forceM2MUnlink) {
+ commands[fieldName].push(x2ManyCommands.forget(removedIds[i]));
+ } else {
+ commands[fieldName].push(x2ManyCommands.delete(removedIds[i]));
+ }
+ }
+ }
+ }
+ }
+ return commands;
+ },
+ /**
+ * Every RPC done by the model need to add some context, which is a
+ * combination of the context of the session, of the record/list, and/or of
+ * the concerned field. This method combines all these contexts and evaluate
+ * them with the proper evalcontext.
+ *
+ * @param {Object} element an element from the localData
+ * @param {Object} [options]
+ * @param {string|Object} [options.additionalContext]
+ * another context to evaluate and merge to the returned context
+ * @param {string} [options.fieldName]
+ * if given, this field's context is added to the context, instead of
+ * the element's context (except if options.full is true)
+ * @param {boolean} [options.full=false]
+ * if true or nor fieldName or additionalContext given in options,
+ * the element's context is added to the context
+ * @returns {Object} the evaluated context
+ */
+ _getContext: function (element, options) {
+ options = options || {};
+ var context = new Context(session.user_context);
+ context.set_eval_context(this._getEvalContext(element));
+
+ if (options.full || !(options.fieldName || options.additionalContext)) {
+ context.add(element.context);
+ }
+ if (options.fieldName) {
+ var viewType = options.viewType || element.viewType;
+ var fieldInfo = element.fieldsInfo[viewType][options.fieldName];
+ if (fieldInfo && fieldInfo.context) {
+ context.add(fieldInfo.context);
+ } else {
+ var fieldParams = element.fields[options.fieldName];
+ if (fieldParams.context) {
+ context.add(fieldParams.context);
+ }
+ }
+ }
+ if (options.additionalContext) {
+ context.add(options.additionalContext);
+ }
+ if (element.rawContext) {
+ var rawContext = new Context(element.rawContext);
+ var evalContext = this._getEvalContext(this.localData[element.parentID]);
+ evalContext.id = evalContext.id || false;
+ rawContext.set_eval_context(evalContext);
+ context.add(rawContext);
+ }
+
+ return context.eval();
+ },
+ /**
+ * Collects from a record a list of ids to fetch, according to fieldName,
+ * and a list of records where to set the result of the fetch.
+ *
+ * @param {Object} list a list containing records we want to get the ids,
+ * it assumes _applyX2ManyOperations and _sort have been already called on
+ * this list
+ * @param {string} fieldName
+ * @return {Object} a list of records and res_ids
+ */
+ _getDataToFetch: function (list, fieldName) {
+ var self = this;
+ var field = list.fields[fieldName];
+ var fieldInfo = list.fieldsInfo[list.viewType][fieldName];
+ var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
+ var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo;
+ var fields = view ? view.fields : fieldInfo.relatedFields;
+ var viewType = view ? view.type : fieldInfo.viewType;
+
+ var toFetch = {};
+
+ // flattens the list.data ids in a grouped case
+ let dataPointIds = list.data;
+ for (let i = 0; i < list.groupedBy.length; i++) {
+ dataPointIds = dataPointIds.reduce((acc, groupId) =>
+ acc.concat(this.localData[groupId].data), []);
+ }
+
+ dataPointIds.forEach(function (dataPoint) {
+ var record = self.localData[dataPoint];
+ if (typeof record.data[fieldName] === 'string'){
+ // in this case, the value is a local ID, which means that the
+ // record has already been processed. It can happen for example
+ // when a user adds a record in a m2m relation, or loads more
+ // records in a kanban column
+ return;
+ }
+
+ _.each(record.data[fieldName], function (id) {
+ toFetch[id] = toFetch[id] || [];
+ toFetch[id].push(record);
+ });
+
+ var m2mList = self._makeDataPoint({
+ fieldsInfo: fieldsInfo,
+ fields: fields,
+ modelName: field.relation,
+ parentID: record.id,
+ res_ids: record.data[fieldName],
+ static: true,
+ type: 'list',
+ viewType: viewType,
+ });
+ record.data[fieldName] = m2mList.id;
+ });
+
+ return toFetch;
+ },
+ /**
+ * Determines and returns from a list a collection of ids classed by
+ * their model.
+ *
+ * @param {Object} list a valid resource object
+ * @param {string} fieldName
+ * @param {Object} [toFetchAcc] an object to store fetching data. Used when
+ * batching reference across multiple groups.
+ * [modelName: string]: {
+ * [recordId: number]: datapointId[]
+ * }
+ * @returns {Object} each key represent a model and contain a sub-object
+ * where each key represent an id (res_id) containing an array of
+ * webclient id (referred to a datapoint, so not a res_id).
+ */
+ _getDataToFetchByModel: function (list, fieldName, toFetchAcc) {
+ var self = this;
+ var toFetch = toFetchAcc || {};
+ _.each(list.data, function (dataPoint) {
+ var record = self.localData[dataPoint];
+ var value = record.data[fieldName];
+ // if the reference field has already been fetched, the value is a
+ // datapoint ID, and in this case there's nothing to do
+ if (value && !self.localData[value]) {
+ var model = value.split(',')[0];
+ var resID = value.split(',')[1];
+ if (!(model in toFetch)) {
+ toFetch[model] = {};
+ }
+ // there could be multiple datapoints with the same model/resID
+ if (toFetch[model][resID]) {
+ toFetch[model][resID].push(dataPoint);
+ } else {
+ toFetch[model][resID] = [dataPoint];
+ }
+ }
+ });
+ return toFetch;
+ },
+ /**
+ * Given a dataPoint of type list (that may be a group), returns an object
+ * with 'default_' keys to be used to create new records in that group.
+ *
+ * @private
+ * @param {Object} dataPoint
+ * @returns {Object}
+ */
+ _getDefaultContext: function (dataPoint) {
+ var defaultContext = {};
+ while (dataPoint.parentID) {
+ var parent = this.localData[dataPoint.parentID];
+ var groupByField = parent.groupedBy[0].split(':')[0];
+ var value = viewUtils.getGroupValue(dataPoint, groupByField);
+ if (value) {
+ defaultContext['default_' + groupByField] = value;
+ }
+ dataPoint = parent;
+ }
+ return defaultContext;
+ },
+ /**
+ * Some records are associated to a/some domain(s). This method allows to
+ * retrieve them, evaluated.
+ *
+ * @param {Object} element an element from the localData
+ * @param {Object} [options]
+ * @param {string} [options.fieldName]
+ * the name of the field whose domain needs to be returned
+ * @returns {Array} the evaluated domain
+ */
+ _getDomain: function (element, options) {
+ if (options && options.fieldName) {
+ if (element._domains[options.fieldName]) {
+ return Domain.prototype.stringToArray(
+ element._domains[options.fieldName],
+ this._getEvalContext(element, true)
+ );
+ }
+ var viewType = options.viewType || element.viewType;
+ var fieldInfo = element.fieldsInfo[viewType][options.fieldName];
+ if (fieldInfo && fieldInfo.domain) {
+ return Domain.prototype.stringToArray(
+ fieldInfo.domain,
+ this._getEvalContext(element, true)
+ );
+ }
+ var fieldParams = element.fields[options.fieldName];
+ if (fieldParams.domain) {
+ return Domain.prototype.stringToArray(
+ fieldParams.domain,
+ this._getEvalContext(element, true)
+ );
+ }
+ return [];
+ }
+
+ return Domain.prototype.stringToArray(
+ element.domain,
+ this._getEvalContext(element, true)
+ );
+ },
+ /**
+ * Returns the evaluation context that should be used when evaluating the
+ * context/domain associated to a given element from the localData.
+ *
+ * It is actually quite subtle. We need to add some magic keys: active_id
+ * and active_ids. Also, the session user context is added in the mix to be
+ * sure. This allows some domains to use the uid key for example
+ *
+ * @param {Object} element - an element from the localData
+ * @param {boolean} [forDomain=false] if true, evaluates x2manys as a list of
+ * ids instead of a list of commands
+ * @returns {Object}
+ */
+ _getEvalContext: function (element, forDomain) {
+ var evalContext = element.type === 'record' ? this._getRecordEvalContext(element, forDomain) : {};
+
+ if (element.parentID) {
+ var parent = this.localData[element.parentID];
+ if (parent.type === 'list' && parent.parentID) {
+ parent = this.localData[parent.parentID];
+ }
+ if (parent.type === 'record') {
+ evalContext.parent = this._getRecordEvalContext(parent, forDomain);
+ }
+ }
+ // Uses "current_company_id" because "company_id" would conflict with all the company_id fields
+ // in general, the actual "company_id" field of the form should be used for m2o domains, not this fallback
+ let current_company_id;
+ if (session.user_context.allowed_company_ids) {
+ current_company_id = session.user_context.allowed_company_ids[0];
+ } else {
+ current_company_id = session.user_companies ?
+ session.user_companies.current_company[0] :
+ false;
+ }
+ return Object.assign(
+ {
+ active_id: evalContext.id || false,
+ active_ids: evalContext.id ? [evalContext.id] : [],
+ active_model: element.model,
+ current_company_id,
+ id: evalContext.id || false,
+ },
+ pyUtils.context(),
+ session.user_context,
+ element.context,
+ evalContext,
+ );
+ },
+ /**
+ * Returns the list of field names of the given element according to its
+ * default view type.
+ *
+ * @param {Object} element an element from the localData
+ * @param {Object} [options]
+ * @param {Object} [options.viewType] current viewType. If not set, we will
+ * assume main viewType from the record
+ * @returns {string[]} the list of field names
+ */
+ _getFieldNames: function (element, options) {
+ var fieldsInfo = element.fieldsInfo;
+ var viewType = options && options.viewType || element.viewType;
+ return Object.keys(fieldsInfo && fieldsInfo[viewType] || {});
+ },
+ /**
+ * Get many2one fields names in a datapoint. This is useful in order to
+ * fetch their names in the case of a default_get.
+ *
+ * @private
+ * @param {Object} datapoint a valid resource object
+ * @returns {string[]} list of field names that are many2one
+ */
+ _getMany2OneFieldNames: function (datapoint) {
+ var many2ones = [];
+ _.each(datapoint.fields, function (field, name) {
+ if (field.type === 'many2one') {
+ many2ones.push(name);
+ }
+ });
+ return many2ones;
+ },
+ /**
+ * Evaluate the record evaluation context. This method is supposed to be
+ * called by _getEvalContext. It basically only generates a dictionary of
+ * current values for the record, with commands for x2manys fields.
+ *
+ * @param {Object} record an element of type 'record'
+ * @param {boolean} [forDomain=false] if true, x2many values are a list of
+ * ids instead of a list of commands
+ * @returns Object
+ */
+ _getRecordEvalContext: function (record, forDomain) {
+ var self = this;
+ var relDataPoint;
+ var context = _.extend({}, record.data, record._changes);
+
+ // calls _generateX2ManyCommands for a given field, and returns the array of commands
+ function _generateX2ManyCommands(fieldName) {
+ var commands = self._generateX2ManyCommands(record, {fieldNames: [fieldName]});
+ return commands[fieldName];
+ }
+
+ for (var fieldName in context) {
+ var field = record.fields[fieldName];
+ if (context[fieldName] === null) {
+ context[fieldName] = false;
+ }
+ if (!field || field.name === 'id') {
+ continue;
+ }
+ if (field.type === 'date' || field.type === 'datetime') {
+ if (context[fieldName]) {
+ context[fieldName] = JSON.parse(JSON.stringify(context[fieldName]));
+ }
+ continue;
+ }
+ if (field.type === 'many2one') {
+ relDataPoint = this.localData[context[fieldName]];
+ context[fieldName] = relDataPoint ? relDataPoint.res_id : false;
+ continue;
+ }
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ var ids;
+ if (!context[fieldName] || _.isArray(context[fieldName])) { // no dataPoint created yet
+ ids = context[fieldName] ? context[fieldName].slice(0) : [];
+ } else {
+ relDataPoint = this._applyX2ManyOperations(this.localData[context[fieldName]]);
+ ids = relDataPoint.res_ids.slice(0);
+ }
+ if (!forDomain) {
+ // when sent to the server, the x2manys values must be a list
+ // of commands in a context, but the list of ids in a domain
+ ids.toJSON = _generateX2ManyCommands.bind(null, fieldName);
+ } else if (field.type === 'one2many') { // Ids are evaluated as a list of ids
+ /* Filtering out virtual ids from the ids list
+ * The server will crash if there are virtual ids in there
+ * The webClient doesn't do literal id list comparison like ids == list
+ * Only relevant in o2m: m2m does create actual records in db
+ */
+ ids = _.filter(ids, function (id) {
+ return typeof id !== 'string';
+ });
+ }
+ context[fieldName] = ids;
+ }
+
+ }
+ return context;
+ },
+ /**
+ * Invalidates the DataManager's cache if the main model (i.e. the model of
+ * its root parent) of the given dataPoint is a model in 'noCacheModels'.
+ *
+ * Reloads the currencies if the main model is 'res.currency'.
+ * Reloads the webclient if we modify a res.company, to (un)activate the
+ * multi-company environment if we are not in a tour test.
+ *
+ * @private
+ * @param {Object} dataPoint
+ */
+ _invalidateCache: function (dataPoint) {
+ while (dataPoint.parentID) {
+ dataPoint = this.localData[dataPoint.parentID];
+ }
+ if (dataPoint.model === 'res.currency') {
+ session.reloadCurrencies();
+ }
+ if (dataPoint.model === 'res.company' && !localStorage.getItem('running_tour')) {
+ this.do_action('reload_context');
+ }
+ if (_.contains(this.noCacheModels, dataPoint.model)) {
+ core.bus.trigger('clear_cache');
+ }
+ },
+ /**
+ * Returns true if the field is protected against changes, looking for a
+ * readonly modifier unless there is a force_save modifier (checking first
+ * in the modifiers, and if there is no readonly modifier, checking the
+ * readonly attribute of the field).
+ *
+ * @private
+ * @param {Object} record an element from the localData
+ * @param {string} fieldName
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
+ * @returns {boolean}
+ */
+ _isFieldProtected: function (record, fieldName, viewType) {
+ viewType = viewType || record.viewType;
+ var fieldInfo = viewType && record.fieldsInfo && record.fieldsInfo[viewType][fieldName];
+ if (fieldInfo) {
+ var rawModifiers = fieldInfo.modifiers || {};
+ var modifiers = this._evalModifiers(record, _.pick(rawModifiers, 'readonly'));
+ return modifiers.readonly && !fieldInfo.force_save;
+ } else {
+ return false;
+ }
+ },
+ /**
+ * Returns true iff value is considered to be set for the given field's type.
+ *
+ * @private
+ * @param {any} value a value for the field
+ * @param {string} fieldType a type of field
+ * @returns {boolean}
+ */
+ _isFieldSet: function (value, fieldType) {
+ switch (fieldType) {
+ case 'boolean':
+ return true;
+ case 'one2many':
+ case 'many2many':
+ return value.length > 0;
+ default:
+ return value !== false;
+ }
+ },
+ /**
+ * return true if a list element is 'valid'. Such an element is valid if it
+ * has no sub record with an unset required field.
+ *
+ * This method is meant to be used to check if a x2many change will trigger
+ * an onchange.
+ *
+ * @param {string} id id for a local resource of type 'list'. This is
+ * assumed to be a list element for an x2many
+ * @returns {boolean}
+ */
+ _isX2ManyValid: function (id) {
+ var self = this;
+ var isValid = true;
+ var element = this.localData[id];
+ _.each(element._changes, function (command) {
+ if (command.operation === 'DELETE' ||
+ command.operation === 'FORGET' ||
+ (command.operation === 'ADD' && !command.isNew)||
+ command.operation === 'REMOVE_ALL') {
+ return;
+ }
+ var recordData = self.get(command.id, {raw: true}).data;
+ var record = self.localData[command.id];
+ _.each(element.getFieldNames(), function (fieldName) {
+ var field = element.fields[fieldName];
+ var fieldInfo = element.fieldsInfo[element.viewType][fieldName];
+ var rawModifiers = fieldInfo.modifiers || {};
+ var modifiers = self._evalModifiers(record, _.pick(rawModifiers, 'required'));
+ if (modifiers.required && !self._isFieldSet(recordData[fieldName], field.type)) {
+ isValid = false;
+ }
+ });
+ });
+ return isValid;
+ },
+ /**
+ * Helper method for the load entry point.
+ *
+ * @see load
+ *
+ * @param {Object} dataPoint some local resource
+ * @param {Object} [options]
+ * @param {string[]} [options.fieldNames] the fields to fetch for a record
+ * @param {boolean} [options.onlyGroups=false]
+ * @param {boolean} [options.keepEmptyGroups=false] if set, the groups not
+ * present in the read_group anymore (empty groups) will stay in the
+ * datapoint (used to mimic the kanban renderer behaviour for example)
+ * @returns {Promise}
+ */
+ _load: function (dataPoint, options) {
+ if (options && options.onlyGroups &&
+ !(dataPoint.type === 'list' && dataPoint.groupedBy.length)) {
+ return Promise.resolve(dataPoint);
+ }
+
+ if (dataPoint.type === 'record') {
+ return this._fetchRecord(dataPoint, options);
+ }
+ if (dataPoint.type === 'list' && dataPoint.groupedBy.length) {
+ return this._readGroup(dataPoint, options);
+ }
+ if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) {
+ return this._fetchUngroupedList(dataPoint, options);
+ }
+ },
+ /**
+ * Turns a bag of properties into a valid local resource. Also, register
+ * the resource in the localData object.
+ *
+ * @param {Object} params
+ * @param {Object} [params.aggregateValues={}]
+ * @param {Object} [params.context={}] context of the action
+ * @param {integer} [params.count=0] number of record being manipulated
+ * @param {Object|Object[]} [params.data={}|[]] data of the record
+ * @param {*[]} [params.domain=[]]
+ * @param {Object} params.fields contains the description of each field
+ * @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field
+ * @param {Object[]} [params.fieldNames] the name of fields to load, the list
+ * of all fields by default
+ * @param {string[]} [params.groupedBy=[]]
+ * @param {boolean} [params.isOpen]
+ * @param {integer} params.limit max number of records shown on screen (pager size)
+ * @param {string} params.modelName
+ * @param {integer} [params.offset]
+ * @param {boolean} [params.openGroupByDefault]
+ * @param {Object[]} [params.orderedBy=[]]
+ * @param {integer[]} [params.orderedResIDs]
+ * @param {string} [params.parentID] model name ID of the parent model
+ * @param {Object} [params.rawContext]
+ * @param {[type]} [params.ref]
+ * @param {string} [params.relationField]
+ * @param {integer|null} [params.res_id] actual id of record in the server
+ * @param {integer[]} [params.res_ids] context in which the data point is used, from a list of res_id
+ * @param {boolean} [params.static=false]
+ * @param {string} [params.type='record'|'list']
+ * @param {[type]} [params.value]
+ * @param {string} [params.viewType] the type of the view, e.g. 'list' or 'form'
+ * @returns {Object} the resource created
+ */
+ _makeDataPoint: function (params) {
+ var type = params.type || ('domain' in params && 'list') || 'record';
+ var res_id, value;
+ var res_ids = params.res_ids || [];
+ var data = params.data || (type === 'record' ? {} : []);
+ var context = params.context;
+ if (type === 'record') {
+ res_id = params.res_id || (params.data && params.data.id);
+ if (res_id) {
+ data.id = res_id;
+ } else {
+ res_id = _.uniqueId('virtual_');
+ }
+ // it doesn't make sense for a record datapoint to have those keys
+ // besides, it will mess up x2m and actions down the line
+ context = _.omit(context, ['orderedBy', 'group_by']);
+ } else {
+ var isValueArray = params.value instanceof Array;
+ res_id = isValueArray ? params.value[0] : undefined;
+ value = isValueArray ? params.value[1] : params.value;
+ }
+
+ var fields = _.extend({
+ display_name: {type: 'char'},
+ id: {type: 'integer'},
+ }, params.fields);
+
+ var dataPoint = {
+ _cache: type === 'list' ? {} : undefined,
+ _changes: null,
+ _domains: {},
+ _rawChanges: {},
+ aggregateValues: params.aggregateValues || {},
+ context: context,
+ count: params.count || res_ids.length,
+ data: data,
+ domain: params.domain || [],
+ fields: fields,
+ fieldsInfo: params.fieldsInfo,
+ groupedBy: params.groupedBy || [],
+ groupsCount: 0,
+ groupsLimit: type === 'list' && params.groupsLimit || null,
+ groupsOffset: 0,
+ id: `${params.modelName}_${++this.__id}`,
+ isOpen: params.isOpen,
+ limit: type === 'record' ? 1 : (params.limit || Number.MAX_SAFE_INTEGER),
+ loadMoreOffset: 0,
+ model: params.modelName,
+ offset: params.offset || (type === 'record' ? _.indexOf(res_ids, res_id) : 0),
+ openGroupByDefault: params.openGroupByDefault,
+ orderedBy: params.orderedBy || [],
+ orderedResIDs: params.orderedResIDs,
+ parentID: params.parentID,
+ rawContext: params.rawContext,
+ ref: params.ref || res_id,
+ relationField: params.relationField,
+ res_id: res_id,
+ res_ids: res_ids,
+ specialData: {},
+ _specialDataCache: {},
+ static: params.static || false,
+ type: type, // 'record' | 'list'
+ value: value,
+ viewType: params.viewType,
+ };
+
+ // _editionViewType is a dict whose keys are field names and which is populated when a field
+ // is edited with the viewType as value. This is useful for one2manys to determine whether
+ // or not a field is readonly (using the readonly modifiers of the view in which the field
+ // has been edited)
+ dataPoint._editionViewType = {};
+
+ dataPoint.evalModifiers = this._evalModifiers.bind(this, dataPoint);
+ dataPoint.getContext = this._getContext.bind(this, dataPoint);
+ dataPoint.getDomain = this._getDomain.bind(this, dataPoint);
+ dataPoint.getFieldNames = this._getFieldNames.bind(this, dataPoint);
+ dataPoint.isDirty = this.isDirty.bind(this, dataPoint.id);
+
+ this.localData[dataPoint.id] = dataPoint;
+
+ return dataPoint;
+ },
+ /**
+ * When one needs to create a record from scratch, a not so simple process
+ * needs to be done:
+ * - call the /default_get route to get default values
+ * - fetch all relational data
+ * - apply all onchanges if necessary
+ * - fetch all relational data
+ *
+ * This method tries to optimize the process as much as possible. Also,
+ * it is quite horrible and should be refactored at some point.
+ *
+ * @private
+ * @param {any} params
+ * @param {string} modelName model name
+ * @param {boolean} [params.allowWarning=false] if true, the default record
+ * operation can complete, even if a warning is raised
+ * @param {Object} params.context the context for the new record
+ * @param {Object} params.fieldsInfo contains the fieldInfo of each view,
+ * for each field
+ * @param {Object} params.fields contains the description of each field
+ * @param {Object} params.context the context for the new record
+ * @param {string} params.viewType the key in fieldsInfo of the fields to load
+ * @returns {Promise<string>} resolves to the id for the created resource
+ */
+ async _makeDefaultRecord(modelName, params) {
+ var targetView = params.viewType;
+ var fields = params.fields;
+ var fieldsInfo = params.fieldsInfo;
+ var fieldNames = Object.keys(fieldsInfo[targetView]);
+
+ // Fields that are present in the originating view, that need to be initialized
+ // Hence preventing their value to crash when getting back to the originating view
+ var parentRecord = params.parentID && this.localData[params.parentID].type === 'list' ? this.localData[params.parentID] : null;
+
+ if (parentRecord && parentRecord.viewType in parentRecord.fieldsInfo) {
+ var originView = parentRecord.viewType;
+ fieldNames = _.union(fieldNames, Object.keys(parentRecord.fieldsInfo[originView]));
+ fieldsInfo[targetView] = _.defaults({}, fieldsInfo[targetView], parentRecord.fieldsInfo[originView]);
+ fields = _.defaults({}, fields, parentRecord.fields);
+ }
+
+ var record = this._makeDataPoint({
+ modelName: modelName,
+ fields: fields,
+ fieldsInfo: fieldsInfo,
+ context: params.context,
+ parentID: params.parentID,
+ res_ids: params.res_ids,
+ viewType: targetView,
+ });
+
+ await this.generateDefaultValues(record.id, {}, { fieldNames });
+ try {
+ await this._performOnChange(record, [], { firstOnChange: true });
+ } finally {
+ if (record._warning && params.allowWarning) {
+ delete record._warning;
+ }
+ }
+ if (record._warning) {
+ return Promise.reject();
+ }
+
+ // We want to overwrite the default value of the handle field (if any),
+ // in order for new lines to be added at the correct position.
+ // -> This is a rare case where the defaul_get from the server
+ // will be ignored by the view for a certain field (usually "sequence").
+ var overrideDefaultFields = this._computeOverrideDefaultFields(params.parentID, params.position);
+ if (overrideDefaultFields.field) {
+ record._changes[overrideDefaultFields.field] = overrideDefaultFields.value;
+ }
+
+ // fetch additional data (special data and many2one namegets for "always_reload" fields)
+ await this._postprocess(record);
+ // save initial changes, so they can be restored later, if we need to discard
+ this.save(record.id, { savePoint: true });
+ return record.id;
+ },
+ /**
+ * parse the server values to javascript framwork
+ *
+ * @param {[string]} fieldNames
+ * @param {Object} element the dataPoint used as parent for the created
+ * dataPoints
+ * @param {Object} data the server data to parse
+ */
+ _parseServerData: function (fieldNames, element, data) {
+ var self = this;
+ _.each(fieldNames, function (fieldName) {
+ var field = element.fields[fieldName];
+ var val = data[fieldName];
+ if (field.type === 'many2one') {
+ // process many2one: split [id, nameget] and create corresponding record
+ if (val !== false) {
+ // the many2one value is of the form [id, display_name]
+ var r = self._makeDataPoint({
+ modelName: field.relation,
+ fields: {
+ display_name: {type: 'char'},
+ id: {type: 'integer'},
+ },
+ data: {
+ display_name: val[1],
+ id: val[0],
+ },
+ parentID: element.id,
+ });
+ data[fieldName] = r.id;
+ } else {
+ // no value for the many2one
+ data[fieldName] = false;
+ }
+ } else {
+ data[fieldName] = self._parseServerValue(field, val);
+ }
+ });
+ },
+ /**
+ * This method is quite important: it is supposed to perform the /onchange
+ * rpc and apply the result.
+ *
+ * The changes that triggered the onchange are assumed to have already been
+ * applied to the record.
+ *
+ * @param {Object} record
+ * @param {string[]} fields changed fields (empty list in the case of first
+ * onchange)
+ * @param {Object} [options={}]
+ * @param {string} [options.viewType] current viewType. If not set, we will assume
+ * main viewType from the record
+ * @param {boolean} [options.firstOnChange=false] set to true if this is the
+ * first onchange
+ * @returns {Promise}
+ */
+ async _performOnChange(record, fields, options = {}) {
+ const firstOnChange = options.firstOnChange;
+ let { hasOnchange, onchangeSpec } = this._buildOnchangeSpecs(record, options.viewType);
+ if (!firstOnChange && !hasOnchange) {
+ return;
+ }
+ var idList = record.data.id ? [record.data.id] : [];
+ const ctxOptions = {
+ full: true,
+ };
+ if (fields.length === 1) {
+ fields = fields[0];
+ // if only one field changed, add its context to the RPC context
+ ctxOptions.fieldName = fields;
+ }
+ var context = this._getContext(record, ctxOptions);
+ var currentData = this._generateOnChangeData(record, {
+ changesOnly: false,
+ firstOnChange,
+ });
+
+ const result = await this._rpc({
+ model: record.model,
+ method: 'onchange',
+ args: [idList, currentData, fields, onchangeSpec],
+ context: context,
+ });
+ if (!record._changes) {
+ // if the _changes key does not exist anymore, it means that
+ // it was removed by discarding the changes after the rpc
+ // to onchange. So, in that case, the proper response is to
+ // ignore the onchange.
+ return;
+ }
+ if (result.warning) {
+ this.trigger_up('warning', result.warning);
+ record._warning = true;
+ }
+ if (result.domain) {
+ record._domains = Object.assign(record._domains, result.domain);
+ }
+ await this._applyOnChange(result.value, record, { firstOnChange });
+ return result;
+ },
+ /**
+ * This function accumulates RPC requests done in the same call stack, and
+ * performs them in the next micro task tick so that similar requests can be
+ * batched in a single RPC.
+ *
+ * For now, only 'read' calls are supported.
+ *
+ * @private
+ * @param {Object} params
+ * @returns {Promise}
+ */
+ _performRPC: function (params) {
+ var self = this;
+
+ // save the RPC request
+ var request = _.extend({}, params);
+ var prom = new Promise(function (resolve, reject) {
+ request.resolve = resolve;
+ request.reject = reject;
+ });
+ this.batchedRPCsRequests.push(request);
+
+ // empty the pool of RPC requests in the next micro tick
+ Promise.resolve().then(function () {
+ if (!self.batchedRPCsRequests.length) {
+ // pool has already been processed
+ return;
+ }
+
+ // reset pool of RPC requests
+ var batchedRPCsRequests = self.batchedRPCsRequests;
+ self.batchedRPCsRequests = [];
+
+ // batch similar requests
+ var batches = {};
+ var key;
+ for (var i = 0; i < batchedRPCsRequests.length; i++) {
+ var request = batchedRPCsRequests[i];
+ key = request.model + ',' + JSON.stringify(request.context);
+ if (!batches[key]) {
+ batches[key] = _.extend({}, request, {requests: [request]});
+ } else {
+ batches[key].ids = _.uniq(batches[key].ids.concat(request.ids));
+ batches[key].fieldNames = _.uniq(batches[key].fieldNames.concat(request.fieldNames));
+ batches[key].requests.push(request);
+ }
+ }
+
+ // perform batched RPCs
+ function onSuccess(batch, results) {
+ for (var i = 0; i < batch.requests.length; i++) {
+ var request = batch.requests[i];
+ var fieldNames = request.fieldNames.concat(['id']);
+ var filteredResults = results.filter(function (record) {
+ return request.ids.indexOf(record.id) >= 0;
+ }).map(function (record) {
+ return _.pick(record, fieldNames);
+ });
+ request.resolve(filteredResults);
+ }
+ }
+ function onFailure(batch, error) {
+ for (var i = 0; i < batch.requests.length; i++) {
+ var request = batch.requests[i];
+ request.reject(error);
+ }
+ }
+ for (key in batches) {
+ var batch = batches[key];
+ self._rpc({
+ model: batch.model,
+ method: 'read',
+ args: [batch.ids, batch.fieldNames],
+ context: batch.context,
+ }).then(onSuccess.bind(null, batch)).guardedCatch(onFailure.bind(null, batch));
+ }
+ });
+
+ return prom;
+ },
+ /**
+ * Once a record is created and some data has been fetched, we need to do
+ * quite a lot of computations to determine what needs to be fetched. This
+ * method is doing that.
+ *
+ * @see _fetchRecord @see _makeDefaultRecord
+ *
+ * @param {Object} record
+ * @param {Object} [options]
+ * @param {Object} [options.viewType] current viewType. If not set, we will
+ * assume main viewType from the record
+ * @returns {Promise<Object>} resolves to the finished resource
+ */
+ _postprocess: function (record, options) {
+ var self = this;
+ var viewType = options && options.viewType || record.viewType;
+ var defs = [];
+
+ _.each(record.getFieldNames(options), function (name) {
+ var field = record.fields[name];
+ var fieldInfo = record.fieldsInfo[viewType][name] || {};
+ var options = fieldInfo.options || {};
+ if (options.always_reload) {
+ if (record.fields[name].type === 'many2one') {
+ const _changes = record._changes || {};
+ const relRecordId = _changes[name] || record.data[name];
+ if (!relRecordId) {
+ return; // field is unset, no need to do the name_get
+ }
+ var relRecord = self.localData[relRecordId];
+ defs.push(self._rpc({
+ model: field.relation,
+ method: 'name_get',
+ args: [relRecord.data.id],
+ context: self._getContext(record, {fieldName: name, viewType: viewType}),
+ })
+ .then(function (result) {
+ relRecord.data.display_name = result[0][1];
+ }));
+ }
+ }
+ });
+
+ defs.push(this._fetchSpecialData(record, options));
+
+ return Promise.all(defs).then(function () {
+ return record;
+ });
+ },
+ /**
+ * Process x2many commands in a default record by transforming the list of
+ * commands in operations (pushed in _changes) and fetch the related
+ * records fields.
+ *
+ * Note that this method can be called recursively.
+ *
+ * @todo in master: factorize this code with the postprocessing of x2many in
+ * _applyOnChange
+ *
+ * @private
+ * @param {Object} record
+ * @param {string} fieldName
+ * @param {Array[Array]} commands
+ * @param {Object} [options]
+ * @param {string} [options.viewType] current viewType. If not set, we will
+ * assume main viewType from the record
+ * @returns {Promise}
+ */
+ _processX2ManyCommands: function (record, fieldName, commands, options) {
+ var self = this;
+ options = options || {};
+ var defs = [];
+ var field = record.fields[fieldName];
+ var fieldInfo = record.fieldsInfo[options.viewType || record.viewType][fieldName] || {};
+ var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
+ var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo;
+ var fields = view ? view.fields : fieldInfo.relatedFields;
+ var viewType = view ? view.type : fieldInfo.viewType;
+
+ // remove default_* keys from parent context to avoid issue of same field name in x2m
+ var parentContext = _.omit(record.context, function (val, key) {
+ return _.str.startsWith(key, 'default_');
+ });
+ var x2manyList = self._makeDataPoint({
+ context: parentContext,
+ fieldsInfo: fieldsInfo,
+ fields: fields,
+ limit: fieldInfo.limit,
+ modelName: field.relation,
+ parentID: record.id,
+ rawContext: fieldInfo && fieldInfo.context,
+ relationField: field.relation_field,
+ res_ids: [],
+ static: true,
+ type: 'list',
+ viewType: viewType,
+ });
+ record._changes[fieldName] = x2manyList.id;
+ x2manyList._changes = [];
+ var many2ones = {};
+ var r;
+ commands = commands || []; // handle false value
+ var isCommandList = commands.length && _.isArray(commands[0]);
+ if (!isCommandList) {
+ commands = [[6, false, commands]];
+ }
+ _.each(commands, function (value) {
+ // value is a command
+ if (value[0] === 0) {
+ // CREATE
+ r = self._makeDataPoint({
+ modelName: x2manyList.model,
+ context: x2manyList.context,
+ fieldsInfo: fieldsInfo,
+ fields: fields,
+ parentID: x2manyList.id,
+ viewType: viewType,
+ });
+ r._noAbandon = true;
+ x2manyList._changes.push({operation: 'ADD', id: r.id});
+ x2manyList._cache[r.res_id] = r.id;
+
+ // this is necessary so the fields are initialized
+ _.each(r.getFieldNames(), function (fieldName) {
+ r.data[fieldName] = null;
+ });
+
+ r._changes = _.defaults(value[2], r.data);
+ for (var fieldName in r._changes) {
+ if (!r._changes[fieldName]) {
+ continue;
+ }
+ var isFieldInView = fieldName in r.fields;
+ if (isFieldInView) {
+ var field = r.fields[fieldName];
+ var fieldType = field.type;
+ var rec;
+ if (fieldType === 'many2one') {
+ rec = self._makeDataPoint({
+ context: r.context,
+ modelName: field.relation,
+ data: {id: r._changes[fieldName]},
+ parentID: r.id,
+ });
+ r._changes[fieldName] = rec.id;
+ many2ones[fieldName] = true;
+ } else if (fieldType === 'reference') {
+ var reference = r._changes[fieldName].split(',');
+ rec = self._makeDataPoint({
+ context: r.context,
+ modelName: reference[0],
+ data: {id: parseInt(reference[1])},
+ parentID: r.id,
+ });
+ r._changes[fieldName] = rec.id;
+ many2ones[fieldName] = true;
+ } else if (_.contains(['one2many', 'many2many'], fieldType)) {
+ var x2mCommands = value[2][fieldName];
+ defs.push(self._processX2ManyCommands(r, fieldName, x2mCommands));
+ } else {
+ r._changes[fieldName] = self._parseServerValue(field, r._changes[fieldName]);
+ }
+ }
+ }
+ }
+ if (value[0] === 6) {
+ // REPLACE_WITH
+ _.each(value[2], function (res_id) {
+ x2manyList._changes.push({operation: 'ADD', resID: res_id});
+ });
+ var def = self._readUngroupedList(x2manyList).then(function () {
+ return Promise.all([
+ self._fetchX2ManysBatched(x2manyList),
+ self._fetchReferencesBatched(x2manyList)
+ ]);
+ });
+ defs.push(def);
+ }
+ });
+
+ // fetch many2ones display_name
+ _.each(_.keys(many2ones), function (name) {
+ defs.push(self._fetchNameGets(x2manyList, name));
+ });
+
+ return Promise.all(defs);
+ },
+ /**
+ * Reads data from server for all missing fields.
+ *
+ * @private
+ * @param {Object} list a valid resource object
+ * @param {interger[]} resIDs
+ * @param {string[]} fieldNames to check and read if missing
+ * @returns {Promise<Object>}
+ */
+ _readMissingFields: function (list, resIDs, fieldNames) {
+ var self = this;
+
+ var missingIDs = [];
+ for (var i = 0, len = resIDs.length; i < len; i++) {
+ var resId = resIDs[i];
+ var dataPointID = list._cache[resId];
+ if (!dataPointID) {
+ missingIDs.push(resId);
+ continue;
+ }
+ var record = self.localData[dataPointID];
+ var data = _.extend({}, record.data, record._changes);
+ if (_.difference(fieldNames, _.keys(data)).length) {
+ missingIDs.push(resId);
+ }
+ }
+
+ var def;
+ if (missingIDs.length && fieldNames.length) {
+ def = self._performRPC({
+ context: list.getContext(),
+ fieldNames: fieldNames,
+ ids: missingIDs,
+ method: 'read',
+ model: list.model,
+ });
+ } else {
+ def = Promise.resolve(_.map(missingIDs, function (id) {
+ return {id:id};
+ }));
+ }
+ return def.then(function (records) {
+ _.each(resIDs, function (id) {
+ var dataPoint;
+ var data = _.findWhere(records, {id: id});
+ if (id in list._cache) {
+ dataPoint = self.localData[list._cache[id]];
+ if (data) {
+ self._parseServerData(fieldNames, dataPoint, data);
+ _.extend(dataPoint.data, data);
+ }
+ } else {
+ dataPoint = self._makeDataPoint({
+ context: list.getContext(),
+ data: data,
+ fieldsInfo: list.fieldsInfo,
+ fields: list.fields,
+ modelName: list.model,
+ parentID: list.id,
+ viewType: list.viewType,
+ });
+ self._parseServerData(fieldNames, dataPoint, dataPoint.data);
+
+ // add many2one records
+ list._cache[id] = dataPoint.id;
+ }
+ // set the dataPoint id in potential 'ADD' operation adding the current record
+ _.each(list._changes, function (change) {
+ if (change.operation === 'ADD' && !change.id && change.resID === id) {
+ change.id = dataPoint.id;
+ }
+ });
+ });
+ return list;
+ });
+ },
+ /**
+ * For a grouped list resource, this method fetches all group data by
+ * performing a /read_group. It also tries to read open subgroups if they
+ * were open before.
+ *
+ * @param {Object} list valid resource object
+ * @param {Object} [options] @see _load
+ * @returns {Promise<Object>} resolves to the fetched group object
+ */
+ _readGroup: function (list, options) {
+ var self = this;
+ options = options || {};
+ var groupByField = list.groupedBy[0];
+ var rawGroupBy = groupByField.split(':')[0];
+ var fields = _.uniq(list.getFieldNames().concat(rawGroupBy));
+ var orderedBy = _.filter(list.orderedBy, function (order) {
+ return order.name === rawGroupBy || list.fields[order.name].group_operator !== undefined;
+ });
+ var openGroupsLimit = list.groupsLimit || self.OPEN_GROUP_LIMIT;
+ var expand = list.openGroupByDefault && options.fetchRecordsWithGroups;
+ return this._rpc({
+ model: list.model,
+ method: 'web_read_group',
+ fields: fields,
+ domain: list.domain,
+ context: list.context,
+ groupBy: list.groupedBy,
+ limit: list.groupsLimit,
+ offset: list.groupsOffset,
+ orderBy: orderedBy,
+ lazy: true,
+ expand: expand,
+ expand_limit: expand ? list.limit : null,
+ expand_orderby: expand ? list.orderedBy : null,
+ })
+ .then(function (result) {
+ var groups = result.groups;
+ list.groupsCount = result.length;
+ var previousGroups = _.map(list.data, function (groupID) {
+ return self.localData[groupID];
+ });
+ list.data = [];
+ list.count = 0;
+ var defs = [];
+ var openGroupCount = 0;
+
+ _.each(groups, function (group) {
+ var aggregateValues = {};
+ _.each(group, function (value, key) {
+ if (_.contains(fields, key) && key !== groupByField &&
+ AGGREGATABLE_TYPES.includes(list.fields[key].type)) {
+ aggregateValues[key] = value;
+ }
+ });
+ // When a view is grouped, we need to display the name of each group in
+ // the 'title'.
+ var value = group[groupByField];
+ if (list.fields[rawGroupBy].type === "selection") {
+ var choice = _.find(list.fields[rawGroupBy].selection, function (c) {
+ return c[0] === value;
+ });
+ value = choice ? choice[1] : false;
+ }
+ var newGroup = self._makeDataPoint({
+ modelName: list.model,
+ count: group[rawGroupBy + '_count'],
+ domain: group.__domain,
+ context: list.context,
+ fields: list.fields,
+ fieldsInfo: list.fieldsInfo,
+ value: value,
+ aggregateValues: aggregateValues,
+ groupedBy: list.groupedBy.slice(1),
+ orderedBy: list.orderedBy,
+ orderedResIDs: list.orderedResIDs,
+ limit: list.limit,
+ openGroupByDefault: list.openGroupByDefault,
+ parentID: list.id,
+ type: 'list',
+ viewType: list.viewType,
+ });
+ var oldGroup = _.find(previousGroups, function (g) {
+ return g.res_id === newGroup.res_id && g.value === newGroup.value;
+ });
+ if (oldGroup) {
+ delete self.localData[newGroup.id];
+ // restore the internal state of the group
+ var updatedProps = _.pick(oldGroup, 'isOpen', 'offset', 'id');
+ if (options.onlyGroups || oldGroup.isOpen && newGroup.groupedBy.length) {
+ // If the group is opened and contains subgroups,
+ // also keep its data to keep internal state of
+ // sub-groups
+ // Also keep data if we only reload groups' own data
+ updatedProps.data = oldGroup.data;
+ if (options.onlyGroups) {
+ // keep count and res_ids as in this case the group
+ // won't be search_read again. This situation happens
+ // when using kanban quick_create where the record is manually
+ // added to the datapoint before getting here.
+ updatedProps.res_ids = oldGroup.res_ids;
+ updatedProps.count = oldGroup.count;
+ }
+ }
+ _.extend(newGroup, updatedProps);
+ // set the limit such that all previously loaded records
+ // (e.g. if we are coming back to the kanban view from a
+ // form view) are reloaded
+ newGroup.limit = oldGroup.limit + oldGroup.loadMoreOffset;
+ self.localData[newGroup.id] = newGroup;
+ } else if (!newGroup.openGroupByDefault || openGroupCount >= openGroupsLimit) {
+ newGroup.isOpen = false;
+ } else if ('__fold' in group) {
+ newGroup.isOpen = !group.__fold;
+ } else {
+ // open the group iff it is a first level group
+ newGroup.isOpen = !self.localData[newGroup.parentID].parentID;
+ }
+ list.data.push(newGroup.id);
+ list.count += newGroup.count;
+ if (newGroup.isOpen && newGroup.count > 0) {
+ openGroupCount++;
+ if (group.__data) {
+ // bypass the search_read when the group's records have been obtained
+ // by the call to 'web_read_group' (see @_searchReadUngroupedList)
+ newGroup.__data = group.__data;
+ }
+ options = _.defaults({enableRelationalFetch: false}, options);
+ defs.push(self._load(newGroup, options));
+ }
+ });
+ if (options.keepEmptyGroups) {
+ // Find the groups that were available in a previous
+ // readGroup but are not there anymore.
+ // Note that these groups are put after existing groups so
+ // the order is not conserved. A sort *might* be useful.
+ var emptyGroupsIDs = _.difference(_.pluck(previousGroups, 'id'), list.data);
+ _.each(emptyGroupsIDs, function (groupID) {
+ list.data.push(groupID);
+ var emptyGroup = self.localData[groupID];
+ // this attribute hasn't been updated in the previous
+ // loop for empty groups
+ emptyGroup.aggregateValues = {};
+ });
+ }
+
+ return Promise.all(defs).then(function (groups) {
+ if (!options.onlyGroups) {
+ // generate the res_ids of the main list, being the concatenation
+ // of the fetched res_ids in each group
+ list.res_ids = _.flatten(_.map(groups, function (group) {
+ return group ? group.res_ids : [];
+ }));
+ }
+ return list;
+ }).then(function () {
+ return Promise.all([
+ self._fetchX2ManysSingleBatch(list),
+ self._fetchReferencesSingleBatch(list)
+ ]).then(function () {
+ return list;
+ });
+ });
+ });
+ },
+ /**
+ * For 'static' list, such as one2manys in a form view, we can do a /read
+ * instead of a /search_read.
+ *
+ * @param {Object} list a valid resource object
+ * @returns {Promise<Object>} resolves to the fetched list object
+ */
+ _readUngroupedList: function (list) {
+ var self = this;
+ var def = Promise.resolve();
+
+ // generate the current count and res_ids list by applying the changes
+ list = this._applyX2ManyOperations(list);
+
+ // for multi-pages list datapoints, we might need to read the
+ // order field first to apply the order on all pages
+ if (list.res_ids.length > list.limit && list.orderedBy.length) {
+ if (!list.orderedResIDs) {
+ var fieldNames = _.pluck(list.orderedBy, 'name');
+ def = this._readMissingFields(list, _.filter(list.res_ids, _.isNumber), fieldNames);
+ }
+ def.then(function () {
+ self._sortList(list);
+ });
+ }
+ return def.then(function () {
+ var resIDs = [];
+ var currentResIDs = list.res_ids;
+ // if new records have been added to the list, their virtual ids have
+ // been pushed at the end of res_ids (or at the beginning, depending
+ // on the editable property), ignoring completely the current page
+ // where the records have actually been created ; for that reason,
+ // we use orderedResIDs which is a freezed order with the virtual ids
+ // at the correct position where they were actually inserted ; however,
+ // when we use orderedResIDs, we must filter out ids that are not in
+ // res_ids, which correspond to records that have been removed from
+ // the relation (this information being taken into account in res_ids
+ // but not in orderedResIDs)
+ if (list.orderedResIDs) {
+ currentResIDs = list.orderedResIDs.filter(function (resID) {
+ return list.res_ids.indexOf(resID) >= 0;
+ });
+ }
+ var currentCount = currentResIDs.length;
+ var upperBound = list.limit ? Math.min(list.offset + list.limit, currentCount) : currentCount;
+ var fieldNames = list.getFieldNames();
+ for (var i = list.offset; i < upperBound; i++) {
+ var resId = currentResIDs[i];
+ if (_.isNumber(resId)) {
+ resIDs.push(resId);
+ }
+ }
+ return self._readMissingFields(list, resIDs, fieldNames).then(function () {
+ if (list.res_ids.length <= list.limit) {
+ self._sortList(list);
+ } else {
+ // sortList has already been applied after first the read
+ self._setDataInRange(list);
+ }
+ return list;
+ });
+ });
+ },
+ /**
+ * Reload all data for a given resource
+ *
+ * @private
+ * @param {string} id local id for a resource
+ * @param {Object} [options]
+ * @param {boolean} [options.keepChanges=false] if true, doesn't discard the
+ * changes on the record before reloading it
+ * @returns {Promise<string>} resolves to the id of the resource
+ */
+ _reload: function (id, options) {
+ options = options || {};
+ var element = this.localData[id];
+
+ if (element.type === 'record') {
+ if (!options.currentId && (('currentId' in options) || this.isNew(id))) {
+ var params = {
+ context: element.context,
+ fieldsInfo: element.fieldsInfo,
+ fields: element.fields,
+ viewType: element.viewType,
+ allowWarning: true,
+ };
+ return this._makeDefaultRecord(element.model, params);
+ }
+ if (!options.keepChanges) {
+ this.discardChanges(id, {rollback: false});
+ }
+ } else if (element._changes) {
+ delete element.tempLimitIncrement;
+ _.each(element._changes, function (change) {
+ delete change.isNew;
+ });
+ }
+
+ if (options.context !== undefined) {
+ element.context = options.context;
+ }
+ if (options.orderedBy !== undefined) {
+ element.orderedBy = (options.orderedBy.length && options.orderedBy) || element.orderedBy;
+ }
+ if (options.domain !== undefined) {
+ element.domain = options.domain;
+ }
+ if (options.groupBy !== undefined) {
+ element.groupedBy = options.groupBy;
+ }
+ if (options.limit !== undefined) {
+ element.limit = options.limit;
+ }
+ if (options.offset !== undefined) {
+ this._setOffset(element.id, options.offset);
+ }
+ if (options.groupsLimit !== undefined) {
+ element.groupsLimit = options.groupsLimit;
+ }
+ if (options.groupsOffset !== undefined) {
+ element.groupsOffset = options.groupsOffset;
+ }
+ if (options.loadMoreOffset !== undefined) {
+ element.loadMoreOffset = options.loadMoreOffset;
+ } else {
+ // reset if not specified
+ element.loadMoreOffset = 0;
+ }
+ if (options.currentId !== undefined) {
+ element.res_id = options.currentId;
+ }
+ if (options.ids !== undefined) {
+ element.res_ids = options.ids;
+ element.count = element.res_ids.length;
+ }
+ if (element.type === 'record') {
+ element.offset = _.indexOf(element.res_ids, element.res_id);
+ }
+ var loadOptions = _.pick(options, 'fieldNames', 'viewType');
+ return this._load(element, loadOptions).then(function (result) {
+ return result.id;
+ });
+ },
+ /**
+ * Override to handle the case where we want sample data, and we are in a
+ * grouped kanban or list view with real groups, but all groups are empty.
+ * In this case, we use the result of the web_read_group rpc to tweak the
+ * data in the SampleServer instance of the sampleModel (so that calls to
+ * that server will return the same groups).
+ *
+ * @override
+ */
+ async _rpc(params) {
+ const result = await this._super(...arguments);
+ if (this.sampleModel && params.method === 'web_read_group' && result.length) {
+ const sampleServer = this.sampleModel.sampleServer;
+ sampleServer.setExistingGroups(result.groups);
+ }
+ return result;
+ },
+ /**
+ * Allows to save a value in the specialData cache associated to a given
+ * record and fieldName. If the value in the cache was already the given
+ * one, nothing is done and the method indicates it by returning false
+ * instead of true.
+ *
+ * @private
+ * @param {Object} record - an element from the localData
+ * @param {string} fieldName - the name of the field
+ * @param {*} value - the cache value to save
+ * @returns {boolean} false if the value was already the given one
+ */
+ _saveSpecialDataCache: function (record, fieldName, value) {
+ if (_.isEqual(record._specialDataCache[fieldName], value)) {
+ return false;
+ }
+ record._specialDataCache[fieldName] = value;
+ return true;
+ },
+ /**
+ * Do a /search_read to get data for a list resource. This does a
+ * /search_read because the data may not be static (for ex, a list view).
+ *
+ * @param {Object} list
+ * @returns {Promise}
+ */
+ _searchReadUngroupedList: function (list) {
+ var self = this;
+ var fieldNames = list.getFieldNames();
+ var prom;
+ if (list.__data) {
+ // the data have already been fetched (alonside the groups by the
+ // call to 'web_read_group'), so we can bypass the search_read
+ prom = Promise.resolve(list.__data);
+ } else {
+ prom = this._rpc({
+ route: '/web/dataset/search_read',
+ model: list.model,
+ fields: fieldNames,
+ context: _.extend({}, list.getContext(), {bin_size: true}),
+ domain: list.domain || [],
+ limit: list.limit,
+ offset: list.loadMoreOffset + list.offset,
+ orderBy: list.orderedBy,
+ });
+ }
+ return prom.then(function (result) {
+ delete list.__data;
+ list.count = result.length;
+ var ids = _.pluck(result.records, 'id');
+ var data = _.map(result.records, function (record) {
+ var dataPoint = self._makeDataPoint({
+ context: list.context,
+ data: record,
+ fields: list.fields,
+ fieldsInfo: list.fieldsInfo,
+ modelName: list.model,
+ parentID: list.id,
+ viewType: list.viewType,
+ });
+
+ // add many2one records
+ self._parseServerData(fieldNames, dataPoint, dataPoint.data);
+ return dataPoint.id;
+ });
+ if (list.loadMoreOffset) {
+ list.data = list.data.concat(data);
+ list.res_ids = list.res_ids.concat(ids);
+ } else {
+ list.data = data;
+ list.res_ids = ids;
+ }
+ self._updateParentResIDs(list);
+ return list;
+ });
+ },
+ /**
+ * Set data in range, i.e. according to the list offset and limit.
+ *
+ * @param {Object} list
+ */
+ _setDataInRange: function (list) {
+ var idsInRange;
+ if (list.limit) {
+ idsInRange = list.res_ids.slice(list.offset, list.offset + list.limit);
+ } else {
+ idsInRange = list.res_ids;
+ }
+ list.data = [];
+ _.each(idsInRange, function (id) {
+ if (list._cache[id]) {
+ list.data.push(list._cache[id]);
+ }
+ });
+
+ // display newly created record in addition to the displayed records
+ if (list.limit) {
+ for (var i = list.offset + list.limit; i < list.res_ids.length; i++) {
+ var id = list.res_ids[i];
+ var dataPointID = list._cache[id];
+ if (_.findWhere(list._changes, {isNew: true, id: dataPointID})) {
+ list.data.push(dataPointID);
+ } else {
+ break;
+ }
+ }
+ }
+ },
+ /**
+ * Change the offset of a record. Note that this does not reload the data.
+ * The offset is used to load a different record in a list of record (for
+ * example, a form view with a pager. Clicking on next/previous actually
+ * changes the offset through this method).
+ *
+ * @param {string} elementId local id for the resource
+ * @param {number} offset
+ */
+ _setOffset: function (elementId, offset) {
+ var element = this.localData[elementId];
+ element.offset = offset;
+ if (element.type === 'record' && element.res_ids.length) {
+ element.res_id = element.res_ids[offset];
+ }
+ },
+ /**
+ * Do a in-memory sort of a list resource data points. This method assumes
+ * that the list data has already been fetched, and that the changes that
+ * need to be sorted have already been applied. Its intended use is for
+ * static datasets, such as a one2many in a form view.
+ *
+ * @param {Object} list list dataPoint on which (some) changes might have
+ * been applied; it is a copy of an internal dataPoint, not the result of
+ * get
+ */
+ _sortList: function (list) {
+ if (!list.static) {
+ // only sort x2many lists
+ return;
+ }
+ var self = this;
+
+ if (list.orderedResIDs) {
+ var orderedResIDs = {};
+ for (var k = 0; k < list.orderedResIDs.length; k++) {
+ orderedResIDs[list.orderedResIDs[k]] = k;
+ }
+ utils.stableSort(list.res_ids, function compareResIdIndexes (resId1, resId2) {
+ if (!(resId1 in orderedResIDs) && !(resId2 in orderedResIDs)) {
+ return 0;
+ }
+ if (!(resId1 in orderedResIDs)) {
+ return Infinity;
+ }
+ if (!(resId2 in orderedResIDs)) {
+ return -Infinity;
+ }
+ return orderedResIDs[resId1] - orderedResIDs[resId2];
+ });
+ } else if (list.orderedBy.length) {
+ // sort records according to ordered_by[0]
+ var compareRecords = function (resId1, resId2, level) {
+ if(!level) {
+ level = 0;
+ }
+ if(list.orderedBy.length < level + 1) {
+ return 0;
+ }
+ var order = list.orderedBy[level];
+ var record1ID = list._cache[resId1];
+ var record2ID = list._cache[resId2];
+ if (!record1ID && !record2ID) {
+ return 0;
+ }
+ if (!record1ID) {
+ return Infinity;
+ }
+ if (!record2ID) {
+ return -Infinity;
+ }
+ var r1 = self.localData[record1ID];
+ var r2 = self.localData[record2ID];
+ var data1 = _.extend({}, r1.data, r1._changes);
+ var data2 = _.extend({}, r2.data, r2._changes);
+
+ // Default value to sort against: the value of the field
+ var orderData1 = data1[order.name];
+ var orderData2 = data2[order.name];
+
+ // If the field is a relation, sort on the display_name of those records
+ if (list.fields[order.name].type === 'many2one') {
+ orderData1 = orderData1 ? self.localData[orderData1].data.display_name : "";
+ orderData2 = orderData2 ? self.localData[orderData2].data.display_name : "";
+ }
+ if (orderData1 < orderData2) {
+ return order.asc ? -1 : 1;
+ }
+ if (orderData1 > orderData2) {
+ return order.asc ? 1 : -1;
+ }
+ return compareRecords(resId1, resId2, level + 1);
+ };
+ utils.stableSort(list.res_ids, compareRecords);
+ }
+ this._setDataInRange(list);
+ },
+ /**
+ * Updates the res_ids of the parent of a given element of type list.
+ *
+ * After some operations (e.g. loading more records, folding/unfolding a
+ * group), the res_ids list of an element may be updated. When this happens,
+ * the res_ids of its ancestors need to be updated as well. This is the
+ * purpose of this function.
+ *
+ * @param {Object} element
+ */
+ _updateParentResIDs: function (element) {
+ var self = this;
+ if (element.parentID) {
+ var parent = this.localData[element.parentID];
+ parent.res_ids = _.flatten(_.map(parent.data, function (dataPointID) {
+ return self.localData[dataPointID].res_ids;
+ }));
+ this._updateParentResIDs(parent);
+ }
+ },
+ /**
+ * Helper method to create datapoints and assign them values, then link
+ * those datapoints into records' data.
+ *
+ * @param {Object[]} records a list of record where datapoints will be
+ * assigned, it assumes _applyX2ManyOperations and _sort have been
+ * already called on this list
+ * @param {string} fieldName concerned field in records
+ * @param {Object[]} values typically a list of values got from a rpc
+ */
+ _updateRecordsData: function (records, fieldName, values) {
+ if (!records.length || !values) {
+ return;
+ }
+ var self = this;
+ var field = records[0].fields[fieldName];
+ var fieldInfo = records[0].fieldsInfo[records[0].viewType][fieldName];
+ var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
+ var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo;
+ var fields = view ? view.fields : fieldInfo.relatedFields;
+ var viewType = view ? view.type : fieldInfo.viewType;
+
+ _.each(records, function (record) {
+ var x2mList = self.localData[record.data[fieldName]];
+ x2mList.data = [];
+ _.each(x2mList.res_ids, function (res_id) {
+ var dataPoint = self._makeDataPoint({
+ modelName: field.relation,
+ data: _.findWhere(values, {id: res_id}),
+ fields: fields,
+ fieldsInfo: fieldsInfo,
+ parentID: x2mList.id,
+ viewType: viewType,
+ });
+ x2mList.data.push(dataPoint.id);
+ x2mList._cache[res_id] = dataPoint.id;
+ });
+ });
+ },
+ /**
+ * Helper method. Recursively traverses the data, starting from the element
+ * record (or list), then following all relations. This is useful when one
+ * want to determine a property for the current record.
+ *
+ * For example, isDirty need to check all relations to find out if something
+ * has been modified, or not.
+ *
+ * Note that this method follows all the changes, so if a record has
+ * relational sub data, it will visit the new sub records and not the old
+ * ones.
+ *
+ * @param {Object} element a valid local resource
+ * @param {callback} fn a function to be called on each visited element
+ */
+ _visitChildren: function (element, fn) {
+ var self = this;
+ fn(element);
+ if (element.type === 'record') {
+ for (var fieldName in element.data) {
+ var field = element.fields[fieldName];
+ if (!field) {
+ continue;
+ }
+ if (_.contains(['one2many', 'many2one', 'many2many'], field.type)) {
+ var hasChange = element._changes && fieldName in element._changes;
+ var value = hasChange ? element._changes[fieldName] : element.data[fieldName];
+ var relationalElement = this.localData[value];
+ // relationalElement could be empty in the case of a many2one
+ if (relationalElement) {
+ self._visitChildren(relationalElement, fn);
+ }
+ }
+ }
+ }
+ if (element.type === 'list') {
+ element = this._applyX2ManyOperations(element);
+ _.each(element.data, function (elemId) {
+ var elem = self.localData[elemId];
+ self._visitChildren(elem, fn);
+ });
+ }
+ },
+});
+
+return BasicModel;
+});
diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js
new file mode 100644
index 00000000..a238ab2e
--- /dev/null
+++ b/addons/web/static/src/js/views/basic/basic_renderer.js
@@ -0,0 +1,926 @@
+odoo.define('web.BasicRenderer', function (require) {
+"use strict";
+
+/**
+ * The BasicRenderer is an abstract class designed to share code between all
+ * views that uses a BasicModel. The main goal is to keep track of all field
+ * widgets, and properly destroy them whenever a rerender is done. The widgets
+ * and modifiers updates mechanism is also shared in the BasicRenderer.
+ */
+var AbstractRenderer = require('web.AbstractRenderer');
+var config = require('web.config');
+var core = require('web.core');
+var dom = require('web.dom');
+const session = require('web.session');
+const utils = require('web.utils');
+var widgetRegistry = require('web.widget_registry');
+
+const { WidgetAdapterMixin } = require('web.OwlCompatibility');
+const FieldWrapper = require('web.FieldWrapper');
+
+var qweb = core.qweb;
+const _t = core._t;
+
+var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, {
+ custom_events: {
+ navigation_move: '_onNavigationMove',
+ },
+ /**
+ * Basic renderers implements the concept of "mode", they can either be in
+ * readonly mode or editable mode.
+ *
+ * @override
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this.activeActions = params.activeActions;
+ this.viewType = params.viewType;
+ this.mode = params.mode || 'readonly';
+ this.widgets = [];
+ // This attribute lets us know if there is a handle widget on a field,
+ // and on which field it is set.
+ this.handleField = null;
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ WidgetAdapterMixin.destroy.call(this);
+ },
+ /**
+ * Called each time the renderer is attached into the DOM.
+ */
+ on_attach_callback: function () {
+ this._isInDom = true;
+ // call on_attach_callback on field widgets
+ for (const handle in this.allFieldWidgets) {
+ this.allFieldWidgets[handle].forEach(widget => {
+ if (!utils.isComponent(widget.constructor) && widget.on_attach_callback) {
+ widget.on_attach_callback();
+ }
+ });
+ }
+ // call on_attach_callback on widgets
+ this.widgets.forEach(widget => {
+ if (widget.on_attach_callback) {
+ widget.on_attach_callback();
+ }
+ });
+ // call on_attach_callback on child components (including field components)
+ WidgetAdapterMixin.on_attach_callback.call(this);
+ },
+ /**
+ * Called each time the renderer is detached from the DOM.
+ */
+ on_detach_callback: function () {
+ this._isInDom = false;
+ // call on_detach_callback on field widgets
+ for (const handle in this.allFieldWidgets) {
+ this.allFieldWidgets[handle].forEach(widget => {
+ if (!utils.isComponent(widget.constructor) && widget.on_detach_callback) {
+ widget.on_detach_callback();
+ }
+ });
+ }
+ // call on_detach_callback on widgets
+ this.widgets.forEach(widget => {
+ if (widget.on_detach_callback) {
+ widget.on_detach_callback();
+ }
+ });
+ // call on_detach_callback on child components (including field components)
+ WidgetAdapterMixin.on_detach_callback.call(this);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method has two responsabilities: find every invalid fields in the
+ * current view, and making sure that they are displayed as invalid, by
+ * toggling the o_form_invalid css class. It has to be done both on the
+ * widget, and on the label, if any.
+ *
+ * @param {string} recordID
+ * @returns {string[]} the list of invalid field names
+ */
+ canBeSaved: function (recordID) {
+ var self = this;
+ var invalidFields = [];
+ _.each(this.allFieldWidgets[recordID], function (widget) {
+ var canBeSaved = self._canWidgetBeSaved(widget);
+ if (!canBeSaved) {
+ invalidFields.push(widget.name);
+ }
+ if (widget.el) { // widget may not be started yet
+ widget.$el.toggleClass('o_field_invalid', !canBeSaved);
+ widget.$el.attr('aria-invalid', !canBeSaved);
+ }
+ });
+ return invalidFields;
+ },
+ /**
+ * Calls 'commitChanges' on all field widgets, so that they can notify the
+ * environment with their current value (useful for widgets that can't
+ * detect when their value changes or that have to validate their changes
+ * before notifying them).
+ *
+ * @param {string} recordID
+ * @return {Promise}
+ */
+ commitChanges: function (recordID) {
+ var defs = _.map(this.allFieldWidgets[recordID], function (widget) {
+ return widget.commitChanges();
+ });
+ return Promise.all(defs);
+ },
+ /**
+ * Updates the internal state of the renderer to the new state. By default,
+ * this also implements the recomputation of the modifiers and their
+ * application to the DOM and the reset of the field widgets if needed.
+ *
+ * In case the given record is not found anymore, a whole re-rendering is
+ * completed (possible if a change in a record caused an onchange which
+ * erased the current record).
+ *
+ * We could always rerender the view from scratch, but then it would not be
+ * as efficient, and we might lose some local state, such as the input focus
+ * cursor, or the scrolling position.
+ *
+ * @param {Object} state
+ * @param {string} id
+ * @param {string[]} fields
+ * @param {OdooEvent} ev
+ * @returns {Promise<AbstractField[]>} resolved with the list of widgets
+ * that have been reset
+ */
+ confirmChange: function (state, id, fields, ev) {
+ var self = this;
+ this._setState(state);
+ var record = this._getRecord(id);
+ if (!record) {
+ return this._render().then(_.constant([]));
+ }
+
+ // reset all widgets (from the <widget> tag) if any:
+ _.invoke(this.widgets, 'updateState', state);
+
+ var defs = [];
+
+ // Reset all the field widgets that are marked as changed and the ones
+ // which are configured to always be reset on any change
+ _.each(this.allFieldWidgets[id], function (widget) {
+ var fieldChanged = _.contains(fields, widget.name);
+ if (fieldChanged || widget.resetOnAnyFieldChange) {
+ defs.push(widget.reset(record, ev, fieldChanged));
+ }
+ });
+
+ // The modifiers update is done after widget resets as modifiers
+ // associated callbacks need to have all the widgets with the proper
+ // state before evaluation
+ defs.push(this._updateAllModifiers(record));
+
+ return Promise.all(defs).then(function () {
+ return _.filter(self.allFieldWidgets[id], function (widget) {
+ var fieldChanged = _.contains(fields, widget.name);
+ return fieldChanged || widget.resetOnAnyFieldChange;
+ });
+ });
+ },
+ /**
+ * Activates the widget and move the cursor to the given offset
+ *
+ * @param {string} id
+ * @param {string} fieldName
+ * @param {integer} offset
+ */
+ focusField: function (id, fieldName, offset) {
+ this.editRecord(id);
+ if (typeof offset === "number") {
+ var field = _.findWhere(this.allFieldWidgets[id], {name: fieldName});
+ dom.setSelectionRange(field.getFocusableElement().get(0), {start: offset, end: offset});
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Activates the widget at the given index for the given record if possible
+ * or the "next" possible one. Usually, a widget can be activated if it is
+ * in edit mode, and if it is visible.
+ *
+ * @private
+ * @param {Object} record
+ * @param {integer} currentIndex
+ * @param {Object} [options={}]
+ * @param {integer} [options.inc=1] - the increment to use when searching for the
+ * "next" possible one
+ * @param {boolean} [options.noAutomaticCreate=false]
+ * @param {boolean} [options.wrap=false] if true, when we arrive at the end of the
+ * list of widget, we wrap around and try to activate widgets starting at
+ * the beginning. Otherwise, we just stop trying and return -1
+ * @returns {integer} the index of the widget that was activated or -1 if
+ * none was possible to activate
+ */
+ _activateFieldWidget: function (record, currentIndex, options) {
+ options = options || {};
+ _.defaults(options, {inc: 1, wrap: false});
+ currentIndex = Math.max(0,currentIndex); // do not allow negative currentIndex
+
+ var recordWidgets = this.allFieldWidgets[record.id] || [];
+ for (var i = 0 ; i < recordWidgets.length ; i++) {
+ var activated = recordWidgets[currentIndex].activate(
+ {
+ event: options.event,
+ noAutomaticCreate: options.noAutomaticCreate || false
+ });
+ if (activated) {
+ return currentIndex;
+ }
+
+ currentIndex += options.inc;
+ if (currentIndex >= recordWidgets.length) {
+ if (options.wrap) {
+ currentIndex -= recordWidgets.length;
+ } else {
+ return -1;
+ }
+ } else if (currentIndex < 0) {
+ if (options.wrap) {
+ currentIndex += recordWidgets.length;
+ } else {
+ return -1;
+ }
+ }
+ }
+ return -1;
+ },
+ /**
+ * This is a wrapper of the {@see _activateFieldWidget} function to select
+ * the next possible widget instead of the given one.
+ *
+ * @private
+ * @param {Object} record
+ * @param {integer} currentIndex
+ * @param {Object|undefined} options
+ * @return {integer}
+ */
+ _activateNextFieldWidget: function (record, currentIndex, options) {
+ currentIndex = (currentIndex + 1) % (this.allFieldWidgets[record.id] || []).length;
+ return this._activateFieldWidget(record, currentIndex, _.extend({inc: 1}, options));
+ },
+ /**
+ * This is a wrapper of the {@see _activateFieldWidget} function to select
+ * the previous possible widget instead of the given one.
+ *
+ * @private
+ * @param {Object} record
+ * @param {integer} currentIndex
+ * @return {integer}
+ */
+ _activatePreviousFieldWidget: function (record, currentIndex) {
+ currentIndex = currentIndex ? (currentIndex - 1) : ((this.allFieldWidgets[record.id] || []).length - 1);
+ return this._activateFieldWidget(record, currentIndex, {inc:-1});
+ },
+ /**
+ * Add a tooltip on a $node, depending on a field description
+ *
+ * @param {FieldWidget} widget
+ * @param {$node} $node
+ */
+ _addFieldTooltip: function (widget, $node) {
+ // optional argument $node, the jQuery element on which the tooltip
+ // should be attached if not given, the tooltip is attached on the
+ // widget's $el
+ $node = $node.length ? $node : widget.$el;
+ $node.tooltip(this._getTooltipOptions(widget));
+ },
+ /**
+ * Does the necessary DOM updates to match the given modifiers data. The
+ * modifiers data is supposed to contain the properly evaluated modifiers
+ * associated to the given records and elements.
+ *
+ * @param {Object} modifiersData
+ * @param {Object} record
+ * @param {Object} [element] - do the update only on this element if given
+ */
+ _applyModifiers: function (modifiersData, record, element) {
+ var self = this;
+ var modifiers = modifiersData.evaluatedModifiers[record.id] || {};
+
+ if (element) {
+ _apply(element);
+ } else {
+ // Clone is necessary as the list might change during _.each
+ _.each(_.clone(modifiersData.elementsByRecord[record.id]), _apply);
+ }
+
+ function _apply(element) {
+ // If the view is in edit mode and that a widget have to switch
+ // its "readonly" state, we have to re-render it completely
+ if ('readonly' in modifiers && element.widget) {
+ var mode = modifiers.readonly ? 'readonly' : modifiersData.baseModeByRecord[record.id];
+ if (mode !== element.widget.mode) {
+ self._rerenderFieldWidget(element.widget, record, {
+ keepBaseMode: true,
+ mode: mode,
+ });
+ return; // Rerendering already applied the modifiers, no need to go further
+ }
+ }
+
+ // Toggle modifiers CSS classes if necessary
+ element.$el.toggleClass("o_invisible_modifier", !!modifiers.invisible);
+ element.$el.toggleClass("o_readonly_modifier", !!modifiers.readonly);
+ element.$el.toggleClass("o_required_modifier", !!modifiers.required);
+
+ if (element.widget && element.widget.updateModifiersValue) {
+ element.widget.updateModifiersValue(modifiers);
+ }
+
+ // Call associated callback
+ if (element.callback) {
+ element.callback(element, modifiers, record);
+ }
+ }
+ },
+ /**
+ * Determines if a given field widget value can be saved. For this to be
+ * true, the widget must be valid (properly parsed value) and have a value
+ * if the associated view field is required.
+ *
+ * @private
+ * @param {AbstractField} widget
+ * @returns {boolean|Promise<boolean>} @see AbstractField.isValid
+ */
+ _canWidgetBeSaved: function (widget) {
+ var modifiers = this._getEvaluatedModifiers(widget.__node, widget.record);
+ return widget.isValid() && (widget.isSet() || !modifiers.required);
+ },
+ /**
+ * Destroys a given widget associated to the given record and removes it
+ * from internal referencing.
+ *
+ * @private
+ * @param {string} recordID id of the local resource
+ * @param {AbstractField} widget
+ * @returns {integer} the index of the removed widget
+ */
+ _destroyFieldWidget: function (recordID, widget) {
+ var recordWidgets = this.allFieldWidgets[recordID];
+ var index = recordWidgets.indexOf(widget);
+ if (index >= 0) {
+ recordWidgets.splice(index, 1);
+ }
+ this._unregisterModifiersElement(widget.__node, recordID, widget);
+ widget.destroy();
+ return index;
+ },
+ /**
+ * Searches for the last evaluation of the modifiers associated to the given
+ * data (modifiers evaluation are supposed to always be up-to-date as soon
+ * as possible).
+ *
+ * @private
+ * @param {Object} node
+ * @param {Object} record
+ * @returns {Object} the evaluated modifiers associated to the given node
+ * and record (not recomputed by the call)
+ */
+ _getEvaluatedModifiers: function (node, record) {
+ var element = this._getModifiersData(node);
+ if (!element) {
+ return {};
+ }
+ return element.evaluatedModifiers[record.id] || {};
+ },
+ /**
+ * Searches through the registered modifiers data for the one which is
+ * related to the given node.
+ *
+ * @private
+ * @param {Object} node
+ * @returns {Object|undefined} related modifiers data if any
+ * undefined otherwise
+ */
+ _getModifiersData: function (node) {
+ return _.findWhere(this.allModifiersData, {node: node});
+ },
+ /**
+ * This function is meant to be overridden in renderers. It takes a dataPoint
+ * id (for a dataPoint of type record), and should return the corresponding
+ * dataPoint.
+ *
+ * @abstract
+ * @private
+ * @param {string} [recordId]
+ * @returns {Object|null}
+ */
+ _getRecord: function (recordId) {
+ return null;
+ },
+ /**
+ * Get the options for the tooltip. This allow to change this options in another module.
+ * @param widget
+ * @return {{}}
+ * @private
+ */
+ _getTooltipOptions: function (widget) {
+ return {
+ title: function () {
+ let help = widget.attrs.help || widget.field.help || '';
+ if (session.display_switch_company_menu && widget.field.company_dependent) {
+ help += (help ? '\n\n' : '') + _t('Values set here are company-specific.');
+ }
+ const debug = config.isDebug();
+ if (help || debug) {
+ return qweb.render('WidgetLabel.tooltip', { debug, help, widget });
+ }
+ }
+ };
+ },
+ /**
+ * @private
+ * @param {jQueryElement} $el
+ * @param {Object} node
+ */
+ _handleAttributes: function ($el, node) {
+ if ($el.is('button')) {
+ return;
+ }
+ if (node.attrs.class) {
+ $el.addClass(node.attrs.class);
+ }
+ if (node.attrs.style) {
+ $el.attr('style', node.attrs.style);
+ }
+ if (node.attrs.placeholder) {
+ $el.attr('placeholder', node.attrs.placeholder);
+ }
+ },
+ /**
+ * Used by list and kanban renderers to determine whether or not to display
+ * the no content helper (if there is no data in the state to display)
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _hasContent: function () {
+ return this.state.count !== 0 && (!('isSample' in this.state) || !this.state.isSample);
+ },
+ /**
+ * Force the resequencing of the records after moving one of them to a given
+ * index.
+ *
+ * @private
+ * @param {string} recordId datapoint id of the moved record
+ * @param {integer} toIndex new index of the moved record
+ */
+ _moveRecord: function (recordId, toIndex) {
+ var self = this;
+ var records = this.state.data;
+ var record = _.findWhere(records, {id: recordId});
+ var fromIndex = records.indexOf(record);
+ var lowerIndex = Math.min(fromIndex, toIndex);
+ var upperIndex = Math.max(fromIndex, toIndex) + 1;
+ var order = _.findWhere(this.state.orderedBy, {name: this.handleField});
+ var asc = !order || order.asc;
+ var reorderAll = false;
+ var sequence = (asc ? -1 : 1) * Infinity;
+
+ // determine if we need to reorder all records
+ _.each(records, function (record, index) {
+ if (((index < lowerIndex || index >= upperIndex) &&
+ ((asc && sequence >= record.data[self.handleField]) ||
+ (!asc && sequence <= record.data[self.handleField]))) ||
+ (index >= lowerIndex && index < upperIndex && sequence === record.data[self.handleField])) {
+ reorderAll = true;
+ }
+ sequence = record.data[self.handleField];
+ });
+
+ if (reorderAll) {
+ records = _.without(records, record);
+ records.splice(toIndex, 0, record);
+ } else {
+ records = records.slice(lowerIndex, upperIndex);
+ records = _.without(records, record);
+ if (fromIndex > toIndex) {
+ records.unshift(record);
+ } else {
+ records.push(record);
+ }
+ }
+
+ var sequences = _.pluck(_.pluck(records, 'data'), this.handleField);
+ var recordIds = _.pluck(records, 'id');
+ if (!asc) {
+ recordIds.reverse();
+ }
+
+ this.trigger_up('resequence_records', {
+ handleField: this.handleField,
+ offset: _.min(sequences),
+ recordIds: recordIds,
+ });
+ },
+ /**
+ * This function is called each time a field widget is created, when it is
+ * ready (after its willStart and Start methods are complete). This is the
+ * place where work having to do with $el should be done.
+ *
+ * @private
+ * @param {Widget} widget the field widget instance
+ * @param {Object} node the attrs coming from the arch
+ */
+ _postProcessField: function (widget, node) {
+ this._handleAttributes(widget.$el, node);
+ },
+ /**
+ * Registers or updates the modifiers data associated to the given node.
+ * This method is quiet complex as it handles all the needs of the basic
+ * renderers:
+ *
+ * - On first registration, the modifiers are evaluated thanks to the given
+ * record. This allows nodes that will produce an AbstractField instance
+ * to have their modifiers registered before this field creation as we
+ * need the readonly modifier to be able to instantiate the AbstractField.
+ *
+ * - On additional registrations, if the node was already registered but the
+ * record is different, we evaluate the modifiers for this record and
+ * saves them in the same object (without reparsing the modifiers).
+ *
+ * - On additional registrations, the modifiers are not reparsed (or
+ * reevaluated for an already seen record) but the given widget or DOM
+ * element is associated to the node modifiers.
+ *
+ * - The new elements are immediately adapted to match the modifiers and the
+ * given associated callback is called even if there is no modifiers on
+ * the node (@see _applyModifiers). This is indeed necessary as the
+ * callback is a description of what to do when a modifier changes. Even
+ * if there is no modifiers, this action must be performed on first
+ * rendering to avoid code duplication. If there is no modifiers, they
+ * will however not be registered for modifiers updates.
+ *
+ * - When a new element is given, it does not replace the old one, it is
+ * added as an additional element. This is indeed useful for nodes that
+ * will produce multiple DOM (as a list cell and its internal widget or
+ * a form field and its associated label).
+ * (@see _unregisterModifiersElement for removing an associated element.)
+ *
+ * Note: also on view rerendering, all the modifiers are forgotten so that
+ * the renderer only keeps the ones associated to the current DOM state.
+ *
+ * @private
+ * @param {Object} node
+ * @param {Object} record
+ * @param {jQuery|AbstractField} [element]
+ * @param {Object} [options]
+ * @param {Object} [options.callback] the callback to call on registration
+ * and on modifiers updates
+ * @param {boolean} [options.keepBaseMode=false] this function registers the
+ * 'baseMode' of the node on a per record basis;
+ * this is a field widget specific settings which
+ * represents the generic mode of the widget, regardless of its modifiers
+ * (the interesting case is the list view: all widgets are supposed to be
+ * in the baseMode 'readonly', except the ones that are in the line that
+ * is currently being edited).
+ * With option 'keepBaseMode' set to true, the baseMode of the record's
+ * node isn't overridden (this is particularily useful when a field widget
+ * is re-rendered because its readonly modifier changed, as in this case,
+ * we don't want to change its base mode).
+ * @param {string} [options.mode] the 'baseMode' of the record's node is set to this
+ * value (if not given, it is set to this.mode, the mode of the renderer)
+ * @returns {Object} for code efficiency, returns the last evaluated
+ * modifiers for the given node and record.
+ * @throws {Error} if one of the modifier domains is not valid
+ */
+ _registerModifiers: function (node, record, element, options) {
+ options = options || {};
+ // Check if we already registered the modifiers for the given node
+ // If yes, this is simply an update of the related element
+ // If not, check the modifiers to see if it needs registration
+ var modifiersData = this._getModifiersData(node);
+ if (!modifiersData) {
+ var modifiers = node.attrs.modifiers || {};
+ modifiersData = {
+ node: node,
+ modifiers: modifiers,
+ evaluatedModifiers: {},
+ elementsByRecord: {},
+ baseModeByRecord : {},
+ };
+ if (!_.isEmpty(modifiers)) { // Register only if modifiers might change (TODO condition might be improved here)
+ this.allModifiersData.push(modifiersData);
+ }
+ }
+
+ // Compute the record's base mode
+ if (!modifiersData.baseModeByRecord[record.id] || !options.keepBaseMode) {
+ modifiersData.baseModeByRecord[record.id] = options.mode || this.mode;
+ }
+
+ // Evaluate if necessary
+ if (!modifiersData.evaluatedModifiers[record.id]) {
+ try {
+ modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers);
+ } catch (e) {
+ throw new Error(_.str.sprintf(
+ "While parsing modifiers for %s%s: %s",
+ node.tag, node.tag === 'field' ? ' ' + node.attrs.name : '',
+ e.message
+ ));
+ }
+ }
+
+ // Element might not be given yet (a second call to the function can
+ // update the registration with the element)
+ if (element) {
+ var newElement = {};
+ if (element instanceof jQuery) {
+ newElement.$el = element;
+ } else {
+ newElement.widget = element;
+ newElement.$el = element.$el;
+ }
+ if (options && options.callback) {
+ newElement.callback = options.callback;
+ }
+
+ if (!modifiersData.elementsByRecord[record.id]) {
+ modifiersData.elementsByRecord[record.id] = [];
+ }
+ modifiersData.elementsByRecord[record.id].push(newElement);
+
+ this._applyModifiers(modifiersData, record, newElement);
+ }
+
+ return modifiersData.evaluatedModifiers[record.id];
+ },
+ /**
+ * @override
+ */
+ async _render() {
+ const oldAllFieldWidgets = this.allFieldWidgets;
+ this.allFieldWidgets = {}; // TODO maybe merging allFieldWidgets and allModifiersData into "nodesData" in some way could be great
+ this.allModifiersData = [];
+ const oldWidgets = this.widgets;
+ this.widgets = [];
+
+ await this._super(...arguments);
+
+ for (const id in oldAllFieldWidgets) {
+ for (const widget of oldAllFieldWidgets[id]) {
+ widget.destroy();
+ }
+ }
+ for (const widget of oldWidgets) {
+ widget.destroy();
+ }
+ if (this._isInDom) {
+ for (const handle in this.allFieldWidgets) {
+ this.allFieldWidgets[handle].forEach(widget => {
+ if (!utils.isComponent(widget.constructor) && widget.on_attach_callback) {
+ widget.on_attach_callback();
+ }
+ });
+ }
+ this.widgets.forEach(widget => {
+ if (widget.on_attach_callback) {
+ widget.on_attach_callback();
+ }
+ });
+ // call on_attach_callback on child components (including field components)
+ WidgetAdapterMixin.on_attach_callback.call(this);
+ }
+ },
+ /**
+ * Instantiates the appropriate AbstractField specialization for the given
+ * node and prepares its rendering and addition to the DOM. Indeed, the
+ * rendering of the widget will be started and the associated promise will
+ * be added to the 'defs' attribute. This is supposed to be created and
+ * deleted by the calling code if necessary.
+ *
+ * Note: we always return a $el. If the field widget is asynchronous, this
+ * $el will be replaced by the real $el, whenever the widget is ready (start
+ * method is done). This means that this is not the correct place to make
+ * changes on the widget $el. For this, @see _postProcessField method
+ *
+ * @private
+ * @param {Object} node
+ * @param {Object} record
+ * @param {Object} [options] passed to @_registerModifiers
+ * @param {string} [options.mode] either 'edit' or 'readonly' (defaults to
+ * this.mode, the mode of the renderer)
+ * @returns {jQueryElement}
+ */
+ _renderFieldWidget: function (node, record, options) {
+ options = options || {};
+ var fieldName = node.attrs.name;
+ // Register the node-associated modifiers
+ var mode = options.mode || this.mode;
+ var modifiers = this._registerModifiers(node, record, null, options);
+ // Initialize and register the widget
+ // Readonly status is known as the modifiers have just been registered
+ var Widget = record.fieldsInfo[this.viewType][fieldName].Widget;
+ const legacy = !(Widget.prototype instanceof owl.Component);
+ const widgetOptions = {
+ mode: modifiers.readonly ? 'readonly' : mode,
+ viewType: this.viewType,
+ };
+ let widget;
+ if (legacy) {
+ widget = new Widget(this, fieldName, record, widgetOptions);
+ } else {
+ widget = new FieldWrapper(this, Widget, {
+ fieldName,
+ record,
+ options: widgetOptions,
+ });
+ }
+
+ // Register the widget so that it can easily be found again
+ if (this.allFieldWidgets[record.id] === undefined) {
+ this.allFieldWidgets[record.id] = [];
+ }
+ this.allFieldWidgets[record.id].push(widget);
+
+ widget.__node = node; // TODO get rid of this if possible one day
+
+ // Prepare widget rendering and save the related promise
+ var $el = $('<div>');
+ let def;
+ if (legacy) {
+ def = widget._widgetRenderAndInsert(function () {});
+ } else {
+ def = widget.mount(document.createDocumentFragment());
+ }
+
+ this.defs.push(def);
+
+ // Update the modifiers registration by associating the widget and by
+ // giving the modifiers options now (as the potential callback is
+ // associated to new widget)
+ var self = this;
+ def.then(function () {
+ // when the caller of renderFieldWidget uses something like
+ // this.renderFieldWidget(...).addClass(...), the class is added on
+ // the temporary div and not on the actual element that will be
+ // rendered. As we do not return a promise and some callers cannot
+ // wait for this.defs, we copy those classnames to the final element.
+ widget.$el.addClass($el.attr('class'));
+
+ $el.replaceWith(widget.$el);
+ self._registerModifiers(node, record, widget, {
+ callback: function (element, modifiers, record) {
+ element.$el.toggleClass('o_field_empty', !!(
+ record.data.id &&
+ (modifiers.readonly || mode === 'readonly') &&
+ element.widget.isEmpty()
+ ));
+ },
+ keepBaseMode: !!options.keepBaseMode,
+ mode: mode,
+ });
+ self._postProcessField(widget, node);
+ });
+
+ return $el;
+ },
+ /**
+ * Instantiate custom widgets
+ *
+ * @private
+ * @param {Object} record
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderWidget: function (record, node) {
+ var Widget = widgetRegistry.get(node.attrs.name);
+ var widget = new Widget(this, record, node);
+
+ this.widgets.push(widget);
+
+ // Prepare widget rendering and save the related promise
+ var def = widget._widgetRenderAndInsert(function () {});
+ this.defs.push(def);
+ var $el = $('<div>');
+
+ var self = this;
+ def.then(function () {
+ self._handleAttributes(widget.$el, node);
+ self._registerModifiers(node, record, widget);
+ widget.$el.addClass('o_widget');
+ $el.replaceWith(widget.$el);
+ });
+
+ return $el;
+ },
+ /**
+ * Rerenders a given widget and make sure the associated data which
+ * referenced the old one is updated.
+ *
+ * @private
+ * @param {Widget} widget
+ * @param {Object} record
+ * @param {Object} [options] options passed to @_renderFieldWidget
+ */
+ _rerenderFieldWidget: function (widget, record, options) {
+ // Render the new field widget
+ var $el = this._renderFieldWidget(widget.__node, record, options);
+ // get the new widget that has just been pushed in allFieldWidgets
+ const recordWidgets = this.allFieldWidgets[record.id];
+ const newWidget = recordWidgets[recordWidgets.length - 1];
+ const def = this.defs[this.defs.length - 1]; // this is the widget's def, resolved when it is ready
+ const $div = $('<div>');
+ $div.append($el); // $el will be replaced when widget is ready (see _renderFieldWidget)
+ def.then(() => {
+ widget.$el.replaceWith($div.children());
+
+ // Destroy the old widget and position the new one at the old one's
+ // (it has been temporarily inserted at the end of the list)
+ recordWidgets.splice(recordWidgets.indexOf(newWidget), 1);
+ var oldIndex = this._destroyFieldWidget(record.id, widget);
+ recordWidgets.splice(oldIndex, 0, newWidget);
+
+ // Mount new widget if necessary (mainly for Owl components)
+ if (this._isInDom && newWidget.on_attach_callback) {
+ newWidget.on_attach_callback();
+ }
+ });
+ },
+ /**
+ * Unregisters an element of the modifiers data associated to the given
+ * node and record.
+ *
+ * @param {Object} node
+ * @param {string} recordID id of the local resource
+ * @param {jQuery|AbstractField} element
+ */
+ _unregisterModifiersElement: function (node, recordID, element) {
+ var modifiersData = this._getModifiersData(node);
+ if (modifiersData) {
+ var elements = modifiersData.elementsByRecord[recordID];
+ var index = _.findIndex(elements, function (oldElement) {
+ return oldElement.widget === element
+ || oldElement.$el[0] === element[0];
+ });
+ if (index >= 0) {
+ elements.splice(index, 1);
+ }
+ }
+ },
+ /**
+ * Does two actions, for each registered modifiers:
+ * 1) Recomputes the modifiers associated to the given record and saves them
+ * (as boolean values) in the appropriate modifiers data.
+ * 2) Updates the rendering of the view elements associated to the given
+ * record to match the new modifiers.
+ *
+ * @see _applyModifiers
+ *
+ * @private
+ * @param {Object} record
+ * @returns {Promise} resolved once finished
+ */
+ _updateAllModifiers: function (record) {
+ var self = this;
+
+ var defs = [];
+ this.defs = defs; // Potentially filled by widget rerendering
+ _.each(this.allModifiersData, function (modifiersData) {
+ // `allModifiersData` might contain modifiers registered for other
+ // records than the given record (e.g. <groupby> in list)
+ if (record.id in modifiersData.evaluatedModifiers) {
+ modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers);
+ self._applyModifiers(modifiersData, record);
+ }
+ });
+ delete this.defs;
+
+ return Promise.all(defs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * When someone presses the TAB/UP/DOWN/... key in a widget, it is nice to
+ * be able to navigate in the view (default browser behaviors are disabled
+ * by Odoo).
+ *
+ * @abstract
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNavigationMove: function (ev) {},
+});
+
+return BasicRenderer;
+});
diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js
new file mode 100644
index 00000000..5e3938fb
--- /dev/null
+++ b/addons/web/static/src/js/views/basic/basic_view.js
@@ -0,0 +1,454 @@
+odoo.define('web.BasicView', function (require) {
+"use strict";
+
+/**
+ * The BasicView is an abstract class designed to share code between views that
+ * want to use a basicModel. As of now, it is the form view, the list view and
+ * the kanban view.
+ *
+ * The main focus of this class is to process the arch and extract field
+ * attributes, as well as some other useful informations.
+ */
+
+var AbstractView = require('web.AbstractView');
+var BasicController = require('web.BasicController');
+var BasicModel = require('web.BasicModel');
+var config = require('web.config');
+var fieldRegistry = require('web.field_registry');
+var fieldRegistryOwl = require('web.field_registry_owl');
+var pyUtils = require('web.py_utils');
+var utils = require('web.utils');
+
+var BasicView = AbstractView.extend({
+ config: _.extend({}, AbstractView.prototype.config, {
+ Model: BasicModel,
+ Controller: BasicController,
+ }),
+ viewType: undefined,
+ /**
+ * process the fields_view to find all fields appearing in the views.
+ * list those fields' name in this.fields_name, which will be the list
+ * of fields read when data is fetched.
+ * this.fields is the list of all field's description (the result of
+ * the fields_get), where the fields appearing in the fields_view are
+ * augmented with their attrs and some flags if they require a
+ * particular handling.
+ *
+ * @param {Object} viewInfo
+ * @param {Object} params
+ */
+ init: function (viewInfo, params) {
+ this._super.apply(this, arguments);
+
+ this.fieldsInfo = {};
+ this.fieldsInfo[this.viewType] = this.fieldsView.fieldsInfo[this.viewType];
+
+ this.rendererParams.viewType = this.viewType;
+
+ this.controllerParams.confirmOnDelete = true;
+ this.controllerParams.archiveEnabled = 'active' in this.fields || 'x_active' in this.fields;
+ this.controllerParams.hasButtons =
+ 'action_buttons' in params ? params.action_buttons : true;
+ this.controllerParams.viewId = viewInfo.view_id;
+
+ this.loadParams.fieldsInfo = this.fieldsInfo;
+ this.loadParams.fields = this.fields;
+ this.loadParams.limit = parseInt(this.arch.attrs.limit, 10) || params.limit;
+ this.loadParams.parentID = params.parentID;
+ this.loadParams.viewType = this.viewType;
+ this.recordID = params.recordID;
+
+ this.model = params.model;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns the AbstractField specialization that should be used for the
+ * given field informations. If there is no mentioned specific widget to
+ * use, determines one according the field type.
+ *
+ * @private
+ * @param {string} viewType
+ * @param {Object} field
+ * @param {Object} attrs
+ * @returns {function|null} AbstractField specialization Class
+ */
+ _getFieldWidgetClass: function (viewType, field, attrs) {
+ var FieldWidget;
+ if (attrs.widget) {
+ FieldWidget = fieldRegistryOwl.getAny([viewType + "." + attrs.widget, attrs.widget]) ||
+ fieldRegistry.getAny([viewType + "." + attrs.widget, attrs.widget]);
+ if (!FieldWidget) {
+ console.warn("Missing widget: ", attrs.widget, " for field", attrs.name, "of type", field.type);
+ }
+ } else if (viewType === 'kanban' && field.type === 'many2many') {
+ // we want to display the widget many2manytags in kanban even if it
+ // is not specified in the view
+ FieldWidget = fieldRegistry.get('kanban.many2many_tags');
+ }
+ return FieldWidget ||
+ fieldRegistryOwl.getAny([viewType + "." + field.type, field.type, "abstract"]) ||
+ fieldRegistry.getAny([viewType + "." + field.type, field.type, "abstract"]);
+ },
+ /**
+ * In some cases, we already have a preloaded record
+ *
+ * @override
+ * @private
+ * @returns {Promise}
+ */
+ _loadData: async function (model) {
+ if (this.recordID) {
+ // Add the fieldsInfo of the current view to the given recordID,
+ // as it will be shared between two views, and it must be able to
+ // handle changes on fields that are only on this view.
+ await model.addFieldsInfo(this.recordID, {
+ fields: this.fields,
+ fieldInfo: this.fieldsInfo[this.viewType],
+ viewType: this.viewType,
+ });
+
+ var record = model.get(this.recordID);
+ var viewType = this.viewType;
+ var viewFields = Object.keys(record.fieldsInfo[viewType]);
+ var fieldNames = _.difference(viewFields, Object.keys(record.data));
+ var fieldsInfo = record.fieldsInfo[viewType];
+
+ // Suppose that in a form view, there is an x2many list view with
+ // a field F, and that F is also displayed in the x2many form view.
+ // In this case, F is represented in record.data (as it is known by
+ // the x2many list view), but the loaded information may not suffice
+ // in the form view (e.g. if field is a many2many list in the form
+ // view, or if it is displayed by a widget requiring specialData).
+ // So when this happens, F is added to the list of fieldNames to fetch.
+ _.each(viewFields, (name) => {
+ if (!_.contains(fieldNames, name)) {
+ var fieldType = record.fields[name].type;
+ var fieldInfo = fieldsInfo[name];
+
+ // SpecialData case: field requires specialData that haven't
+ // been fetched yet.
+ if (fieldInfo.Widget) {
+ var requiresSpecialData = fieldInfo.Widget.prototype.specialData;
+ if (requiresSpecialData && !(name in record.specialData)) {
+ fieldNames.push(name);
+ return;
+ }
+ }
+
+ // X2Many case: field is an x2many displayed as a list or
+ // kanban view, but the related fields haven't been loaded yet.
+ if ((fieldType === 'one2many' || fieldType === 'many2many')) {
+ if (!('fieldsInfo' in record.data[name])) {
+ fieldNames.push(name);
+ } else {
+ var x2mFieldInfo = record.fieldsInfo[this.viewType][name];
+ var viewType = x2mFieldInfo.viewType || x2mFieldInfo.mode;
+ var knownFields = Object.keys(record.data[name].fieldsInfo[record.data[name].viewType] || {});
+ var newFields = Object.keys(record.data[name].fieldsInfo[viewType] || {});
+ if (_.difference(newFields, knownFields).length) {
+ fieldNames.push(name);
+ }
+
+ if (record.data[name].viewType === 'default') {
+ // Use case: x2many (tags) in x2many list views
+ // When opening the x2many record form view, the
+ // x2many will be reloaded but it may not have
+ // the same fields (ex: tags in list and list in
+ // form) so we need to merge the fieldsInfo to
+ // avoid losing the initial fields (display_name)
+ var fieldViews = fieldInfo.views || fieldInfo.fieldsInfo || {};
+ var defaultFieldInfo = record.data[name].fieldsInfo.default;
+ _.each(fieldViews, function (fieldView) {
+ _.each(fieldView.fieldsInfo, function (x2mFieldInfo) {
+ _.defaults(x2mFieldInfo, defaultFieldInfo);
+ });
+ });
+ }
+ }
+ }
+ // Many2one: context is not the same between the different views
+ // this means the result of a name_get could differ
+ if (fieldType === 'many2one') {
+ if (JSON.stringify(record.data[name].context) !==
+ JSON.stringify(fieldInfo.context)) {
+ fieldNames.push(name);
+ }
+ }
+ }
+ });
+
+ var def;
+ if (fieldNames.length) {
+ if (model.isNew(record.id)) {
+ def = model.generateDefaultValues(record.id, {
+ fieldNames: fieldNames,
+ viewType: viewType,
+ });
+ } else {
+ def = model.reload(record.id, {
+ fieldNames: fieldNames,
+ keepChanges: true,
+ viewType: viewType,
+ });
+ }
+ }
+ return Promise.resolve(def).then(function () {
+ const handle = record.id;
+ return { state: model.get(handle), handle };
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Traverses the arch and calls '_processNode' on each of its nodes.
+ *
+ * @private
+ * @param {Object} arch a parsed arch
+ * @param {Object} fv the fieldsView Object, in which _processNode can
+ * access and add information (like the fields' attributes in the arch)
+ */
+ _processArch: function (arch, fv) {
+ var self = this;
+ utils.traverse(arch, function (node) {
+ return self._processNode(node, fv);
+ });
+ },
+ /**
+ * Processes a field node, in particular, put a flag on the field to give
+ * special directives to the BasicModel.
+ *
+ * @private
+ * @param {string} viewType
+ * @param {Object} field - the field properties
+ * @param {Object} attrs - the field attributes (from the xml)
+ * @returns {Object} attrs
+ */
+ _processField: function (viewType, field, attrs) {
+ var self = this;
+ attrs.Widget = this._getFieldWidgetClass(viewType, field, attrs);
+
+ // process decoration attributes
+ _.each(attrs, function (value, key) {
+ if (key.startsWith('decoration-')) {
+ attrs.decorations = attrs.decorations || [];
+ attrs.decorations.push({
+ name: key,
+ expression: pyUtils._getPyJSAST(value),
+ });
+ }
+ });
+
+ if (!_.isObject(attrs.options)) { // parent arch could have already been processed (TODO this should not happen)
+ attrs.options = attrs.options ? pyUtils.py_eval(attrs.options) : {};
+ }
+
+ if (attrs.on_change && attrs.on_change !== "0" && !field.onChange) {
+ field.onChange = "1";
+ }
+
+ // the relational data of invisible relational fields should not be
+ // fetched (e.g. name_gets of invisible many2ones), at least those that
+ // are always invisible.
+ // the invisible attribute of a field is supposed to be static ("1" in
+ // general), but not totally as it may use keys of the context
+ // ("context.get('some_key')"). It is evaluated server-side, and the
+ // result is put inside the modifiers as a value of the '(column_)invisible'
+ // key, and the raw value is left in the invisible attribute (it is used
+ // in debug mode for informational purposes).
+ // this should change, for instance the server might set the evaluated
+ // value in invisible, which could then be seen as static by the client,
+ // and add another key in debug mode containing the raw value.
+ // for now, we look inside the modifiers and consider the value only if
+ // it is static (=== true),
+ if (attrs.modifiers.invisible === true || attrs.modifiers.column_invisible === true) {
+ attrs.__no_fetch = true;
+ }
+
+ if (!_.isEmpty(field.views)) {
+ // process the inner fields_view as well to find the fields they use.
+ // register those fields' description directly on the view.
+ // for those inner views, the list of all fields isn't necessary, so
+ // basically the field_names will be the keys of the fields obj.
+ // don't use _ to iterate on fields in case there is a 'length' field,
+ // as _ doesn't behave correctly when there is a length key in the object
+ attrs.views = {};
+ _.each(field.views, function (innerFieldsView, viewType) {
+ viewType = viewType === 'tree' ? 'list' : viewType;
+ attrs.views[viewType] = self._processFieldsView(innerFieldsView, viewType);
+ });
+ }
+
+ attrs.views = attrs.views || {};
+
+ // Keep compatibility with 'tree' syntax
+ attrs.mode = attrs.mode === 'tree' ? 'list' : attrs.mode;
+ if (!attrs.views.list && attrs.views.tree) {
+ attrs.views.list = attrs.views.tree;
+ }
+
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ if (attrs.Widget.prototype.useSubview) {
+ var mode = attrs.mode;
+ if (!mode) {
+ if (attrs.views.list && !attrs.views.kanban) {
+ mode = 'list';
+ } else if (!attrs.views.list && attrs.views.kanban) {
+ mode = 'kanban';
+ } else {
+ mode = 'list,kanban';
+ }
+ }
+ if (mode.indexOf(',') !== -1) {
+ mode = config.device.isMobile ? 'kanban' : 'list';
+ }
+ attrs.mode = mode;
+ if (mode in attrs.views) {
+ var view = attrs.views[mode];
+ this._processSubViewAttrs(view, attrs);
+ }
+ }
+ if (attrs.Widget.prototype.fieldsToFetch) {
+ attrs.viewType = 'default';
+ attrs.relatedFields = _.extend({}, attrs.Widget.prototype.fieldsToFetch);
+ attrs.fieldsInfo = {
+ default: _.mapObject(attrs.Widget.prototype.fieldsToFetch, function () {
+ return {};
+ }),
+ };
+ if (attrs.options.color_field) {
+ // used by m2m tags
+ attrs.relatedFields[attrs.options.color_field] = { type: 'integer' };
+ attrs.fieldsInfo.default[attrs.options.color_field] = {};
+ }
+ }
+ }
+
+ if (attrs.Widget.prototype.fieldDependencies) {
+ attrs.fieldDependencies = attrs.Widget.prototype.fieldDependencies;
+ }
+
+ return attrs;
+ },
+ /**
+ * Overrides to process the fields, and generate fieldsInfo which contains
+ * the description of the fields in view, with their attrs in the arch.
+ *
+ * @override
+ * @private
+ * @param {Object} fieldsView
+ * @param {string} fieldsView.arch
+ * @param {Object} fieldsView.fields
+ * @param {string} [viewType] by default, this.viewType
+ * @returns {Object} the processed fieldsView with extra key 'fieldsInfo'
+ */
+ _processFieldsView: function (fieldsView, viewType) {
+ var fv = this._super.apply(this, arguments);
+
+ viewType = viewType || this.viewType;
+ fv.type = viewType;
+ fv.fieldsInfo = Object.create(null);
+ fv.fieldsInfo[viewType] = Object.create(null);
+
+ this._processArch(fv.arch, fv);
+
+ return fv;
+ },
+ /**
+ * Processes a node of the arch (mainly nodes with tagname 'field'). Can
+ * be overridden to handle other tagnames.
+ *
+ * @private
+ * @param {Object} node
+ * @param {Object} fv the fieldsView
+ * @param {Object} fv.fieldsInfo
+ * @param {Object} fv.fieldsInfo[viewType] fieldsInfo of the current viewType
+ * @param {Object} fv.viewFields the result of a fields_get extend with the
+ * fields returned with the fields_view_get for the current viewType
+ * @param {string} fv.viewType
+ * @returns {boolean} false iff subnodes must not be visited.
+ */
+ _processNode: function (node, fv) {
+ if (typeof node === 'string') {
+ return false;
+ }
+ if (!_.isObject(node.attrs.modifiers)) {
+ node.attrs.modifiers = node.attrs.modifiers ? JSON.parse(node.attrs.modifiers) : {};
+ }
+ if (!_.isObject(node.attrs.options) && node.tag === 'button') {
+ node.attrs.options = node.attrs.options ? JSON.parse(node.attrs.options) : {};
+ }
+ if (node.tag === 'field') {
+ var viewType = fv.type;
+ var fieldsInfo = fv.fieldsInfo[viewType];
+ var fields = fv.viewFields;
+ fieldsInfo[node.attrs.name] = this._processField(viewType,
+ fields[node.attrs.name], node.attrs ? _.clone(node.attrs) : {});
+
+ if (fieldsInfo[node.attrs.name].fieldDependencies) {
+ var deps = fieldsInfo[node.attrs.name].fieldDependencies;
+ for (var dependency_name in deps) {
+ var dependency_dict = {name: dependency_name, type: deps[dependency_name].type};
+ if (!(dependency_name in fieldsInfo)) {
+ fieldsInfo[dependency_name] = _.extend({}, dependency_dict, {
+ options: deps[dependency_name].options || {},
+ });
+ }
+ if (!(dependency_name in fields)) {
+ fields[dependency_name] = dependency_dict;
+ }
+
+ if (fv.fields && !(dependency_name in fv.fields)) {
+ fv.fields[dependency_name] = dependency_dict;
+ }
+ }
+ }
+ return false;
+ }
+ return node.tag !== 'arch';
+ },
+ /**
+ * Processes in place the subview attributes (in particular,
+ * `default_order``and `column_invisible`).
+ *
+ * @private
+ * @param {Object} view - the field subview
+ * @param {Object} attrs - the field attributes (from the xml)
+ */
+ _processSubViewAttrs: function (view, attrs) {
+ var defaultOrder = view.arch.attrs.default_order;
+ if (defaultOrder) {
+ // process the default_order, which is like 'name,id desc'
+ // but we need it like [{name: 'name', asc: true}, {name: 'id', asc: false}]
+ attrs.orderedBy = _.map(defaultOrder.split(','), function (order) {
+ order = order.trim().split(' ');
+ return {name: order[0], asc: order[1] !== 'desc'};
+ });
+ } else {
+ // if there is a field with widget `handle`, the x2many
+ // needs to be ordered by this field to correctly display
+ // the records
+ var handleField = _.find(view.arch.children, function (child) {
+ return child.attrs && child.attrs.widget === 'handle';
+ });
+ if (handleField) {
+ attrs.orderedBy = [{name: handleField.attrs.name, asc: true}];
+ }
+ }
+
+ attrs.columnInvisibleFields = {};
+ _.each(view.arch.children, function (child) {
+ if (child.attrs && child.attrs.modifiers) {
+ attrs.columnInvisibleFields[child.attrs.name] =
+ child.attrs.modifiers.column_invisible || false;
+ }
+ });
+ },
+});
+
+return BasicView;
+
+});
diff --git a/addons/web/static/src/js/views/basic/widget_registry.js b/addons/web/static/src/js/views/basic/widget_registry.js
new file mode 100644
index 00000000..470127bd
--- /dev/null
+++ b/addons/web/static/src/js/views/basic/widget_registry.js
@@ -0,0 +1,27 @@
+odoo.define('web.widget_registry', function (require) {
+ "use strict";
+
+ // This registry is supposed to contain all custom widgets that will be
+ // available in the basic views, with the tag <widget/>. There are
+ // currently no such widget in the web client, but the functionality is
+ // certainly useful to be able to cleanly add custom behaviour in basic
+ // views (and most notably, the form view)
+ //
+ // The way custom widgets work is that they register themselves to this
+ // registry:
+ //
+ // widgetRegistry.add('some_name', MyWidget);
+ //
+ // Then, they are available with the <widget/> tag (in the arch):
+ //
+ // <widget name="some_name"/>
+ //
+ // Widgets will be then properly instantiated, rendered and destroyed at the
+ // appropriate time, with the current state in second argument.
+ //
+ // For more examples, look at the tests (grep '<widget' in the test folder)
+
+ var Registry = require('web.Registry');
+
+ return new Registry();
+});
diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js
new file mode 100644
index 00000000..0ab74b34
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_controller.js
@@ -0,0 +1,477 @@
+odoo.define('web.CalendarController', function (require) {
+"use strict";
+
+/**
+ * Calendar Controller
+ *
+ * This is the controller in the Model-Renderer-Controller architecture of the
+ * calendar view. Its role is to coordinate the data from the calendar model
+ * with the renderer, and with the outside world (such as a search view input)
+ */
+
+var AbstractController = require('web.AbstractController');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var dialogs = require('web.view_dialogs');
+var QuickCreate = require('web.CalendarQuickCreate');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+function dateToServer (date, fieldType) {
+ date = date.clone().locale('en');
+ if (fieldType === "date") {
+ return date.local().format('YYYY-MM-DD');
+ }
+ return date.utc().format('YYYY-MM-DD HH:mm:ss');
+}
+
+var CalendarController = AbstractController.extend({
+ custom_events: _.extend({}, AbstractController.prototype.custom_events, {
+ changeDate: '_onChangeDate',
+ changeFilter: '_onChangeFilter',
+ deleteRecord: '_onDeleteRecord',
+ dropRecord: '_onDropRecord',
+ next: '_onNext',
+ openCreate: '_onOpenCreate',
+ openEvent: '_onOpenEvent',
+ prev: '_onPrev',
+ quickCreate: '_onQuickCreate',
+ updateRecord: '_onUpdateRecord',
+ viewUpdated: '_onViewUpdated',
+ }),
+ events: _.extend({}, AbstractController.prototype.events, {
+ 'click button.o_calendar_button_new': '_onButtonNew',
+ 'click button.o_calendar_button_prev': '_onButtonNavigation',
+ 'click button.o_calendar_button_today': '_onButtonNavigation',
+ 'click button.o_calendar_button_next': '_onButtonNavigation',
+ 'click button.o_calendar_button_day': '_onButtonScale',
+ 'click button.o_calendar_button_week': '_onButtonScale',
+ 'click button.o_calendar_button_month': '_onButtonScale',
+ 'click button.o_calendar_button_year': '_onButtonScale',
+ }),
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {AbstractModel} model
+ * @param {AbstractRenderer} renderer
+ * @param {Object} params
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.current_start = null;
+ this.displayName = params.displayName;
+ this.quickAddPop = params.quickAddPop;
+ this.disableQuickCreate = params.disableQuickCreate;
+ this.eventOpenPopup = params.eventOpenPopup;
+ this.showUnusualDays = params.showUnusualDays;
+ this.formViewId = params.formViewId;
+ this.readonlyFormViewId = params.readonlyFormViewId;
+ this.mapping = params.mapping;
+ this.context = params.context;
+ this.previousOpen = null;
+ // The quickCreating attribute ensures that we don't do several create
+ this.quickCreating = false;
+ this.scales = params.scales;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Render the buttons according to the CalendarView.buttons template and
+ * add listeners on it. Set this.$buttons with the produced jQuery element
+ *
+ * @param {jQuery} [$node] a jQuery node where the rendered buttons
+ * should be inserted. $node may be undefined, in which case the Calendar
+ * inserts them into this.options.$buttons or into a div of its template
+ */
+ renderButtons: function ($node) {
+ this.$buttons = $(QWeb.render('CalendarView.buttons', this._renderButtonsParameters()));
+
+ this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active');
+
+ if ($node) {
+ this.$buttons.appendTo($node);
+ } else {
+ this.$('.o_calendar_buttons').replaceWith(this.$buttons);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Find a className in an array using the start of this class and
+ * return the last part of a string
+ * @private
+ * @param {string} startClassName start of string to find in the "array"
+ * @param {array|DOMTokenList} classList array of all class
+ * @return {string|undefined}
+ */
+ _extractLastPartOfClassName(startClassName, classList) {
+ var result;
+ classList.forEach(function (value) {
+ if (value && value.indexOf(startClassName) === 0) {
+ result = value.substring(startClassName.length);
+ }
+ });
+ return result;
+ },
+ /**
+ * Move to the requested direction and reload the view
+ *
+ * @private
+ * @param {string} to either 'prev', 'next' or 'today'
+ * @returns {Promise}
+ */
+ _move: function (to) {
+ this.model[to]();
+ return this.reload();
+ },
+ /**
+ * Parameter send to QWeb to render the template of Buttons
+ *
+ * @private
+ * @return {{}}
+ */
+ _renderButtonsParameters() {
+ return {
+ scales: this.scales,
+ };
+ },
+ /**
+ * @override
+ * @private
+ */
+ _update: function () {
+ var self = this;
+ if (!this.showUnusualDays) {
+ return this._super.apply(this, arguments);
+ }
+ return this._super.apply(this, arguments).then(function () {
+ self._rpc({
+ model: self.modelName,
+ method: 'get_unusual_days',
+ args: [dateToServer(self.model.data.start_date, 'date'), dateToServer(self.model.data.end_date, 'date')],
+ context: self.context,
+ }).then(function (data) {
+ _.each(self.$el.find('td.fc-day'), function (td) {
+ var $td = $(td);
+ if (data[$td.data('date')]) {
+ $td.addClass('o_calendar_disabled');
+ }
+ });
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {Object} record
+ * @param {integer} record.id
+ * @returns {Promise}
+ */
+ _updateRecord: function (record) {
+ var reload = this.reload.bind(this, {});
+ return this.model.updateRecord(record).then(reload, reload);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handler when a user clicks on button to create event
+ *
+ * @private
+ */
+ _onButtonNew() {
+ this.trigger_up('switch_view', {view_type: 'form'});
+ },
+ /**
+ * Handler when a user click on navigation button like prev, next, ...
+ *
+ * @private
+ * @param {Event|jQueryEvent} jsEvent
+ */
+ _onButtonNavigation(jsEvent) {
+ const action = this._extractLastPartOfClassName('o_calendar_button_', jsEvent.currentTarget.classList);
+ if (action) {
+ this._move(action);
+ }
+ },
+ /**
+ * Handler when a user click on scale button like day, month, ...
+ *
+ * @private
+ * @param {Event|jQueryEvent} jsEvent
+ */
+ _onButtonScale(jsEvent) {
+ const scale = this._extractLastPartOfClassName('o_calendar_button_', jsEvent.currentTarget.classList);
+ if (scale) {
+ this.model.setScale(scale);
+ this.reload();
+ }
+ },
+
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onChangeDate: function (event) {
+ var modelData = this.model.get();
+ if (modelData.target_date.format('YYYY-MM-DD') === event.data.date.format('YYYY-MM-DD')) {
+ // When clicking on same date, toggle between the two views
+ switch (modelData.scale) {
+ case 'month': this.model.setScale('week'); break;
+ case 'week': this.model.setScale('day'); break;
+ case 'day': this.model.setScale('month'); break;
+ }
+ } else if (modelData.target_date.week() === event.data.date.week()) {
+ // When clicking on a date in the same week, switch to day view
+ this.model.setScale('day');
+ } else {
+ // When clicking on a random day of a random other week, switch to week view
+ this.model.setScale('week');
+ }
+ this.model.setDate(event.data.date);
+ this.reload();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onChangeFilter: function (event) {
+ if (this.model.changeFilter(event.data) && !event.data.no_reload) {
+ this.reload();
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onDeleteRecord: function (event) {
+ var self = this;
+ Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), {
+ confirm_callback: function () {
+ self.model.deleteRecords([event.data.id], self.modelName).then(function () {
+ self.reload();
+ });
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onDropRecord: function (event) {
+ this._updateRecord(_.extend({}, event.data, {
+ 'drop': true,
+ }));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onNext: function (event) {
+ event.stopPropagation();
+ this._move('next');
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onOpenCreate: function (event) {
+ var self = this;
+ if (["year", "month"].includes(this.model.get().scale)) {
+ event.data.allDay = true;
+ }
+ var data = this.model.calendarEventToRecord(event.data);
+
+ var context = _.extend({}, this.context, event.options && event.options.context);
+ // context default has more priority in default_get so if data.name is false then it may
+ // lead to error/warning while saving record in form view as name field can be required
+ if (data.name) {
+ context.default_name = data.name;
+ }
+ context['default_' + this.mapping.date_start] = data[this.mapping.date_start] || null;
+ if (this.mapping.date_stop) {
+ context['default_' + this.mapping.date_stop] = data[this.mapping.date_stop] || null;
+ }
+ if (this.mapping.date_delay) {
+ context['default_' + this.mapping.date_delay] = data[this.mapping.date_delay] || null;
+ }
+ if (this.mapping.all_day) {
+ context['default_' + this.mapping.all_day] = data[this.mapping.all_day] || null;
+ }
+
+ for (var k in context) {
+ if (context[k] && context[k]._isAMomentObject) {
+ context[k] = dateToServer(context[k]);
+ }
+ }
+
+ var options = _.extend({}, this.options, event.options, {
+ context: context,
+ title: _.str.sprintf(_t('Create: %s'), (this.displayName || this.renderer.arch.attrs.string))
+ });
+
+ if (this.quick != null) {
+ this.quick.destroy();
+ this.quick = null;
+ }
+
+ if (!options.disableQuickCreate && !event.data.disableQuickCreate && this.quickAddPop) {
+ this.quick = new QuickCreate(this, true, options, data, event.data);
+ this.quick.open();
+ this.quick.opened(function () {
+ self.quick.focus();
+ });
+ return;
+ }
+
+ var title = _t("Create");
+ if (this.renderer.arch.attrs.string) {
+ title += ': ' + this.renderer.arch.attrs.string;
+ }
+ if (this.eventOpenPopup) {
+ if (this.previousOpen) { this.previousOpen.close(); }
+ this.previousOpen = new dialogs.FormViewDialog(self, {
+ res_model: this.modelName,
+ context: context,
+ title: title,
+ view_id: this.formViewId || false,
+ disable_multiple_selection: true,
+ on_saved: function () {
+ if (event.data.on_save) {
+ event.data.on_save();
+ }
+ self.reload();
+ },
+ });
+ this.previousOpen.open();
+ } else {
+ this.do_action({
+ type: 'ir.actions.act_window',
+ res_model: this.modelName,
+ views: [[this.formViewId || false, 'form']],
+ target: 'current',
+ context: context,
+ });
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onOpenEvent: function (event) {
+ var self = this;
+ var id = event.data._id;
+ id = id && parseInt(id).toString() === id ? parseInt(id) : id;
+
+ if (!this.eventOpenPopup) {
+ this._rpc({
+ model: self.modelName,
+ method: 'get_formview_id',
+ //The event can be called by a view that can have another context than the default one.
+ args: [[id]],
+ context: event.context || self.context,
+ }).then(function (viewId) {
+ self.do_action({
+ type:'ir.actions.act_window',
+ res_id: id,
+ res_model: self.modelName,
+ views: [[viewId || false, 'form']],
+ target: 'current',
+ context: event.context || self.context,
+ });
+ });
+ return;
+ }
+
+ var options = {
+ res_model: self.modelName,
+ res_id: id || null,
+ context: event.context || self.context,
+ title: _t("Open: ") + _.escape(event.data.title),
+ on_saved: function () {
+ if (event.data.on_save) {
+ event.data.on_save();
+ }
+ self.reload();
+ },
+ };
+ if (this.formViewId) {
+ options.view_id = parseInt(this.formViewId);
+ }
+ new dialogs.FormViewDialog(this, options).open();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onPrev: function () {
+ event.stopPropagation();
+ this._move('prev');
+ },
+
+ /**
+ * Handles saving data coming from quick create box
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onQuickCreate: function (event) {
+ var self = this;
+ if (this.quickCreating) {
+ return;
+ }
+ this.quickCreating = true;
+ this.model.createRecord(event)
+ .then(function () {
+ self.quick.destroy();
+ self.quick = null;
+ self.reload();
+ self.quickCreating = false;
+ })
+ .guardedCatch(function (result) {
+ var errorEvent = result.event;
+ // This will occurs if there are some more fields required
+ // Preventdefaulting the error event will prevent the traceback window
+ errorEvent.preventDefault();
+ event.data.options.disableQuickCreate = true;
+ event.data.data.on_save = self.quick.destroy.bind(self.quick);
+ self._onOpenCreate(event.data);
+ self.quickCreating = false;
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onUpdateRecord: function (event) {
+ this._updateRecord(event.data);
+ },
+ /**
+ * The internal state of the calendar (mode, period displayed) has changed,
+ * so update the control panel buttons and breadcrumbs accordingly.
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onViewUpdated: function (event) {
+ this.mode = event.data.mode;
+ if (this.$buttons) {
+ this.$buttons.find('.active').removeClass('active');
+ this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active');
+ }
+ const title = `${this.displayName} (${event.data.title})`;
+ return this.updateControlPanel({ title });
+ },
+});
+
+return CalendarController;
+
+});
diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js
new file mode 100644
index 00000000..93999bee
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_model.js
@@ -0,0 +1,777 @@
+odoo.define('web.CalendarModel', function (require) {
+"use strict";
+
+var AbstractModel = require('web.AbstractModel');
+var Context = require('web.Context');
+var core = require('web.core');
+var fieldUtils = require('web.field_utils');
+var session = require('web.session');
+
+var _t = core._t;
+
+function dateToServer (date) {
+ return date.clone().utc().locale('en').format('YYYY-MM-DD HH:mm:ss');
+}
+
+return AbstractModel.extend({
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.end_date = null;
+ var week_start = _t.database.parameters.week_start;
+ // calendar uses index 0 for Sunday but Odoo stores it as 7
+ this.week_start = week_start !== undefined && week_start !== false ? week_start % 7 : moment().startOf('week').day();
+ this.week_stop = this.week_start + 6;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Transform fullcalendar event object to OpenERP Data object
+ */
+ calendarEventToRecord: function (event) {
+ // Normalize event_end without changing fullcalendars event.
+ var data = {'name': event.title};
+ var start = event.start.clone();
+ var end = event.end && event.end.clone();
+
+ // Set end date if not existing
+ if (!end || end.diff(start) < 0) { // undefined or invalid end date
+ if (event.allDay) {
+ end = start.clone();
+ } else {
+ // in week mode or day mode, convert allday event to event
+ end = start.clone().add(2, 'h');
+ }
+ } else if (event.allDay) {
+ // For an "allDay", FullCalendar gives the end day as the
+ // next day at midnight (instead of 23h59).
+ end.add(-1, 'days');
+ }
+
+ var isDateEvent = this.fields[this.mapping.date_start].type === 'date';
+ // An "allDay" event without the "all_day" option is not considered
+ // as a 24h day. It's just a part of the day (by default: 7h-19h).
+ if (event.allDay) {
+ if (!this.mapping.all_day && !isDateEvent) {
+ if (event.r_start) {
+ start.hours(event.r_start.hours())
+ .minutes(event.r_start.minutes())
+ .seconds(event.r_start.seconds())
+ .utc();
+ end.hours(event.r_end.hours())
+ .minutes(event.r_end.minutes())
+ .seconds(event.r_end.seconds())
+ .utc();
+ } else {
+ // default hours in the user's timezone
+ start.hours(7);
+ end.hours(19);
+ }
+ start.add(-this.getSession().getTZOffset(start), 'minutes');
+ end.add(-this.getSession().getTZOffset(end), 'minutes');
+ }
+ } else {
+ start.add(-this.getSession().getTZOffset(start), 'minutes');
+ end.add(-this.getSession().getTZOffset(end), 'minutes');
+ }
+
+ if (this.mapping.all_day) {
+ if (event.record) {
+ data[this.mapping.all_day] =
+ (this.data.scale !== 'month' && event.allDay) ||
+ event.record[this.mapping.all_day] &&
+ end.diff(start) < 10 ||
+ false;
+ } else {
+ data[this.mapping.all_day] = event.allDay;
+ }
+ }
+
+ data[this.mapping.date_start] = start;
+ if (this.mapping.date_stop) {
+ data[this.mapping.date_stop] = end;
+ }
+
+ if (this.mapping.date_delay) {
+ if (this.data.scale !== 'month' || (this.data.scale === 'month' && !event.drop)) {
+ data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600;
+ }
+ }
+
+ return data;
+ },
+ /**
+ * @param {Object} filter
+ * @returns {boolean}
+ */
+ changeFilter: function (filter) {
+ var Filter = this.data.filters[filter.fieldName];
+ if (filter.value === 'all') {
+ Filter.all = filter.active;
+ }
+ var f = _.find(Filter.filters, function (f) {
+ return f.value === filter.value;
+ });
+ if (f) {
+ if (f.active !== filter.active) {
+ f.active = filter.active;
+ } else {
+ return false;
+ }
+ } else if (filter.active) {
+ Filter.filters.push({
+ value: filter.value,
+ active: true,
+ });
+ }
+ return true;
+ },
+ /**
+ * @param {OdooEvent} event
+ */
+ createRecord: function (event) {
+ var data = this.calendarEventToRecord(event.data.data);
+ for (var k in data) {
+ if (data[k] && data[k]._isAMomentObject) {
+ data[k] = dateToServer(data[k]);
+ }
+ }
+ return this._rpc({
+ model: this.modelName,
+ method: 'create',
+ args: [data],
+ context: event.data.options.context,
+ });
+ },
+ /**
+ * @todo I think this is dead code
+ *
+ * @param {any} ids
+ * @param {any} model
+ * @returns
+ */
+ deleteRecords: function (ids, model) {
+ return this._rpc({
+ model: model,
+ method: 'unlink',
+ args: [ids],
+ context: session.user_context, // todo: combine with view context
+ });
+ },
+ /**
+ * @override
+ * @returns {Object}
+ */
+ __get: function () {
+ return _.extend({}, this.data, {
+ fields: this.fields
+ });
+ },
+ /**
+ * @override
+ * @param {any} params
+ * @returns {Promise}
+ */
+ __load: function (params) {
+ var self = this;
+ this.modelName = params.modelName;
+ this.fields = params.fields;
+ this.fieldNames = params.fieldNames;
+ this.fieldsInfo = params.fieldsInfo;
+ this.mapping = params.mapping;
+ this.mode = params.mode; // one of month, week or day
+ this.scales = params.scales; // one of month, week or day
+ this.scalesInfo = params.scalesInfo;
+
+ // Check whether the date field is editable (i.e. if the events can be
+ // dragged and dropped)
+ this.editable = params.editable;
+ this.creatable = params.creatable;
+
+ // display more button when there are too much event on one day
+ this.eventLimit = params.eventLimit;
+
+ // fields to display color, e.g.: user_id.partner_id
+ this.fieldColor = params.fieldColor;
+ if (!this.preloadPromise) {
+ this.preloadPromise = new Promise(function (resolve, reject) {
+ Promise.all([
+ self._rpc({model: self.modelName, method: 'check_access_rights', args: ["write", false]}),
+ self._rpc({model: self.modelName, method: 'check_access_rights', args: ["create", false]})
+ ]).then(function (result) {
+ var write = result[0];
+ var create = result[1];
+ self.write_right = write;
+ self.create_right = create;
+ resolve();
+ }).guardedCatch(reject);
+ });
+ }
+
+ this.data = {
+ domain: params.domain,
+ context: params.context,
+ // get in arch the filter to display in the sidebar and the field to read
+ filters: params.filters,
+ };
+
+ this.setDate(params.initialDate);
+ // Use mode attribute in xml file to specify zoom timeline (day,week,month)
+ // by default month.
+ this.setScale(params.mode);
+
+ _.each(this.data.filters, function (filter) {
+ if (filter.avatar_field && !filter.avatar_model) {
+ filter.avatar_model = self.modelName;
+ }
+ });
+
+ return this.preloadPromise.then(this._loadCalendar.bind(this));
+ },
+ /**
+ * Move the current date range to the next period
+ */
+ next: function () {
+ this.setDate(this.data.target_date.clone().add(1, this.data.scale));
+ },
+ /**
+ * Move the current date range to the previous period
+ */
+ prev: function () {
+ this.setDate(this.data.target_date.clone().add(-1, this.data.scale));
+ },
+ /**
+ * @override
+ * @param {Object} [params.context]
+ * @param {Array} [params.domain]
+ * @returns {Promise}
+ */
+ __reload: function (handle, params) {
+ if (params.domain) {
+ this.data.domain = params.domain;
+ }
+ if (params.context) {
+ this.data.context = params.context;
+ }
+ return this._loadCalendar();
+ },
+ /**
+ * @param {Moment} start. in local TZ
+ */
+ setDate: function (start) {
+ // keep highlight/target_date in localtime
+ this.data.highlight_date = this.data.target_date = start.clone();
+ this.data.start_date = this.data.end_date = start;
+ switch (this.data.scale) {
+ case 'year': {
+ const yearStart = this.data.start_date.clone().startOf('year');
+ let yearStartDay = this.week_start;
+ if (yearStart.day() < yearStartDay) {
+ // the 1st of January is before our week start (e.g. week start is Monday, and
+ // 01/01 is Sunday), so we go one week back
+ yearStartDay -= 7;
+ }
+ this.data.start_date = yearStart.day(yearStartDay).startOf('day');
+ this.data.end_date = this.data.end_date.clone()
+ .endOf('year').day(this.week_stop).endOf('day');
+ break;
+ }
+ case 'month':
+ var monthStart = this.data.start_date.clone().startOf('month');
+
+ var monthStartDay;
+ if (monthStart.day() >= this.week_start) {
+ // the month's first day is after our week start
+ // Then we are in the right week
+ monthStartDay = this.week_start;
+ } else {
+ // The month's first day is before our week start
+ // Then we should go back to the the previous week
+ monthStartDay = this.week_start - 7;
+ }
+
+ this.data.start_date = monthStart.day(monthStartDay).startOf('day');
+ this.data.end_date = this.data.start_date.clone().add(5, 'week').day(this.week_stop).endOf('day');
+ break;
+ case 'week':
+ var weekStart = this.data.start_date.clone().startOf('week');
+ var weekStartDay = this.week_start;
+ if (this.data.start_date.day() < this.week_start) {
+ // The week's first day is after our current day
+ // Then we should go back to the previous week
+ weekStartDay -= 7;
+ }
+ this.data.start_date = this.data.start_date.clone().day(weekStartDay).startOf('day');
+ this.data.end_date = this.data.end_date.clone().day(weekStartDay + 6).endOf('day');
+ break;
+ default:
+ this.data.start_date = this.data.start_date.clone().startOf('day');
+ this.data.end_date = this.data.end_date.clone().endOf('day');
+ }
+ // We have set start/stop datetime as definite begin/end boundaries of a period (month, week, day)
+ // in local TZ (what is the begining of the week *I am* in ?)
+ // The following code:
+ // - converts those to UTC using our homemade method (testable)
+ // - sets the moment UTC flag to true, to ensure compatibility with third party libs
+ var manualUtcDateStart = this.data.start_date.clone().add(-this.getSession().getTZOffset(this.data.start_date), 'minutes');
+ var formattedUtcDateStart = manualUtcDateStart.format('YYYY-MM-DDTHH:mm:ss') + 'Z';
+ this.data.start_date = moment.utc(formattedUtcDateStart);
+
+ var manualUtcDateEnd = this.data.end_date.clone().add(-this.getSession().getTZOffset(this.data.start_date), 'minutes');
+ var formattedUtcDateEnd = manualUtcDateEnd.format('YYYY-MM-DDTHH:mm:ss') + 'Z';
+ this.data.end_date = moment.utc(formattedUtcDateEnd);
+ },
+ /**
+ * @param {string} scale the scale to set
+ */
+ setScale: function (scale) {
+ if (!_.contains(this.scales, scale)) {
+ scale = "week";
+ }
+ this.data.scale = scale;
+ this.setDate(this.data.target_date);
+ },
+ /**
+ * Move the current date range to the period containing today
+ */
+ today: function () {
+ this.setDate(moment(new Date()));
+ },
+ /**
+ * @param {Object} record
+ * @param {integer} record.id
+ * @returns {Promise}
+ */
+ updateRecord: function (record) {
+ // Cannot modify actual name yet
+ var data = _.omit(this.calendarEventToRecord(record), 'name');
+ for (var k in data) {
+ if (data[k] && data[k]._isAMomentObject) {
+ data[k] = dateToServer(data[k]);
+ }
+ }
+ var context = new Context(this.data.context, {from_ui: true});
+ return this._rpc({
+ model: this.modelName,
+ method: 'write',
+ args: [[parseInt(record.id, 10)], data],
+ context: context
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Converts this.data.filters into a domain
+ *
+ * @private
+ * @returns {Array}
+ */
+ _getFilterDomain: function () {
+ // List authorized values for every field
+ // fields with an active 'all' filter are skipped
+ var authorizedValues = {};
+ var avoidValues = {};
+
+ _.each(this.data.filters, function (filter) {
+ // Skip 'all' filters because they do not affect the domain
+ if (filter.all) return;
+
+ // Loop over subfilters to complete authorizedValues
+ _.each(filter.filters, function (f) {
+ if (filter.write_model) {
+ if (!authorizedValues[filter.fieldName])
+ authorizedValues[filter.fieldName] = [];
+
+ if (f.active) {
+ authorizedValues[filter.fieldName].push(f.value);
+ }
+ } else {
+ if (!f.active) {
+ if (!avoidValues[filter.fieldName])
+ avoidValues[filter.fieldName] = [];
+
+ avoidValues[filter.fieldName].push(f.value);
+ }
+ }
+ });
+ });
+
+ // Compute the domain
+ var domain = [];
+ for (var field in authorizedValues) {
+ domain.push([field, 'in', authorizedValues[field]]);
+ }
+ for (var field in avoidValues) {
+ if (avoidValues[field].length > 0) {
+ domain.push([field, 'not in', avoidValues[field]]);
+ }
+ }
+
+ return domain;
+ },
+ /**
+ * @private
+ * @returns {Object}
+ */
+ _getFullCalendarOptions: function () {
+ var format12Hour = {
+ hour: 'numeric',
+ minute: '2-digit',
+ omitZeroMinute: true,
+ meridiem: 'short'
+ };
+ var format24Hour = {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: false,
+ };
+ return {
+ defaultView: this.scalesInfo[this.mode || 'week'],
+ header: false,
+ selectable: this.creatable && this.create_right,
+ selectMirror: true,
+ editable: this.editable,
+ droppable: true,
+ navLinks: false,
+ eventLimit: this.eventLimit, // allow "more" link when too many events
+ snapMinutes: 15,
+ longPressDelay: 500,
+ eventResizableFromStart: true,
+ nowIndicator: true,
+ weekNumbers: true,
+ weekNumbersWithinDays: true,
+ weekNumberCalculation: function (date) {
+ // Since FullCalendar v4 ISO 8601 week date is preferred so we force the old system
+ return moment(date).week();
+ },
+ weekLabel: _t("Week"),
+ allDayText: _t("All day"),
+ monthNames: moment.months(),
+ monthNamesShort: moment.monthsShort(),
+ dayNames: moment.weekdays(),
+ dayNamesShort: moment.weekdaysShort(),
+ firstDay: this.week_start,
+ slotLabelFormat: _t.database.parameters.time_format.search("%H") !== -1 ? format24Hour : format12Hour,
+ allDaySlot: this.mapping.all_day || this.fields[this.mapping.date_start].type === 'date',
+ };
+ },
+ /**
+ * Return a domain from the date range
+ *
+ * @private
+ * @returns {Array} A domain containing datetimes start and stop in UTC
+ * those datetimes are formatted according to server's standards
+ */
+ _getRangeDomain: function () {
+ // Build OpenERP Domain to filter object by this.mapping.date_start field
+ // between given start, end dates.
+ var domain = [[this.mapping.date_start, '<=', dateToServer(this.data.end_date)]];
+ if (this.mapping.date_stop) {
+ domain.push([this.mapping.date_stop, '>=', dateToServer(this.data.start_date)]);
+ } else if (!this.mapping.date_delay) {
+ domain.push([this.mapping.date_start, '>=', dateToServer(this.data.start_date)]);
+ }
+ return domain;
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _loadCalendar: function () {
+ var self = this;
+ this.data.fc_options = this._getFullCalendarOptions();
+
+ var defs = _.map(this.data.filters, this._loadFilter.bind(this));
+
+ return Promise.all(defs).then(function () {
+ return self._rpc({
+ model: self.modelName,
+ method: 'search_read',
+ context: self.data.context,
+ fields: self.fieldNames,
+ domain: self.data.domain.concat(self._getRangeDomain()).concat(self._getFilterDomain())
+ })
+ .then(function (events) {
+ self._parseServerData(events);
+ self.data.data = _.map(events, self._recordToCalendarEvent.bind(self));
+ return Promise.all([
+ self._loadColors(self.data, self.data.data),
+ self._loadRecordsToFilters(self.data, self.data.data)
+ ]);
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {any} element
+ * @param {any} events
+ * @returns {Promise}
+ */
+ _loadColors: function (element, events) {
+ if (this.fieldColor) {
+ var fieldName = this.fieldColor;
+ _.each(events, function (event) {
+ var value = event.record[fieldName];
+ event.color_index = _.isArray(value) ? value[0] % 30 : value % 30;
+ });
+ this.model_color = this.fields[fieldName].relation || element.model;
+ }
+ return Promise.resolve();
+ },
+ /**
+ * @private
+ * @param {any} filter
+ * @returns {Promise}
+ */
+ _loadFilter: function (filter) {
+ if (!filter.write_model) {
+ return Promise.resolve();
+ }
+
+ var field = this.fields[filter.fieldName];
+ return this._rpc({
+ model: filter.write_model,
+ method: 'search_read',
+ domain: [["user_id", "=", session.uid]],
+ fields: [filter.write_field],
+ })
+ .then(function (res) {
+ var records = _.map(res, function (record) {
+ var _value = record[filter.write_field];
+ var value = _.isArray(_value) ? _value[0] : _value;
+ var f = _.find(filter.filters, function (f) {return f.value === value;});
+ var formater = fieldUtils.format[_.contains(['many2many', 'one2many'], field.type) ? 'many2one' : field.type];
+ return {
+ 'id': record.id,
+ 'value': value,
+ 'label': formater(_value, field),
+ 'active': !f || f.active,
+ };
+ });
+ records.sort(function (f1,f2) {
+ return _.string.naturalCmp(f2.label, f1.label);
+ });
+
+ // add my profile
+ if (field.relation === 'res.partner' || field.relation === 'res.users') {
+ var value = field.relation === 'res.partner' ? session.partner_id : session.uid;
+ var me = _.find(records, function (record) {
+ return record.value === value;
+ });
+ if (me) {
+ records.splice(records.indexOf(me), 1);
+ } else {
+ var f = _.find(filter.filters, function (f) {return f.value === value;});
+ me = {
+ 'value': value,
+ 'label': session.name + _t(" [Me]"),
+ 'active': !f || f.active,
+ };
+ }
+ records.unshift(me);
+ }
+ // add all selection
+ records.push({
+ 'value': 'all',
+ 'label': field.relation === 'res.partner' || field.relation === 'res.users' ? _t("Everybody's calendars") : _t("Everything"),
+ 'active': filter.all,
+ });
+
+ filter.filters = records;
+ });
+ },
+ /**
+ * @private
+ * @param {any} element
+ * @param {any} events
+ * @returns {Promise}
+ */
+ _loadRecordsToFilters: function (element, events) {
+ var self = this;
+ var new_filters = {};
+ var to_read = {};
+ var defs = [];
+ var color_filter = {};
+
+ _.each(this.data.filters, function (filter, fieldName) {
+ var field = self.fields[fieldName];
+
+ new_filters[fieldName] = filter;
+ if (filter.write_model) {
+ if (field.relation === self.model_color) {
+ _.each(filter.filters, function (f) {
+ f.color_index = f.value;
+ });
+ }
+ return;
+ }
+
+ _.each(filter.filters, function (filter) {
+ filter.display = !filter.active;
+ });
+
+ var fs = [];
+ var undefined_fs = [];
+ _.each(events, function (event) {
+ var data = event.record[fieldName];
+ if (!_.contains(['many2many', 'one2many'], field.type)) {
+ data = [data];
+ } else {
+ to_read[field.relation] = (to_read[field.relation] || []).concat(data);
+ }
+ _.each(data, function (_value) {
+ var value = _.isArray(_value) ? _value[0] : _value;
+ var f = {
+ 'color_index': self.model_color === (field.relation || element.model) ? value % 30 : false,
+ 'value': value,
+ 'label': fieldUtils.format[field.type](_value, field) || _t("Undefined"),
+ 'avatar_model': field.relation || element.model,
+ };
+ // if field used as color does not have value then push filter in undefined_fs,
+ // such filters should come last in filter list with Undefined string, later merge it with fs
+ value ? fs.push(f) : undefined_fs.push(f);
+ });
+ });
+ _.each(_.union(fs, undefined_fs), function (f) {
+ var f1 = _.findWhere(filter.filters, _.omit(f, 'color_index'));
+ if (f1) {
+ f1.display = true;
+ } else {
+ f.display = f.active = true;
+ filter.filters.push(f);
+ }
+ });
+
+ if (filter.color_model && filter.field_color) {
+ var ids = filter.filters.reduce((acc, f) => {
+ if (!f.color_index && f.value) {
+ acc.push(f.value);
+ }
+ return acc;
+ }, []);
+ if (!color_filter[filter.color_model]) {
+ color_filter[filter.color_model] = {};
+ }
+ if (ids.length) {
+ defs.push(self._rpc({
+ model: filter.color_model,
+ method: 'read',
+ args: [_.uniq(ids), [filter.field_color]],
+ })
+ .then(function (res) {
+ _.each(res, function (c) {
+ color_filter[filter.color_model][c.id] = c[filter.field_color];
+ });
+ }));
+ }
+ }
+ });
+
+ _.each(to_read, function (ids, model) {
+ defs.push(self._rpc({
+ model: model,
+ method: 'name_get',
+ args: [_.uniq(ids)],
+ })
+ .then(function (res) {
+ to_read[model] = _.object(res);
+ }));
+ });
+ return Promise.all(defs).then(function () {
+ _.each(self.data.filters, function (filter) {
+ if (filter.write_model) {
+ return;
+ }
+ if (filter.filters.length && (filter.filters[0].avatar_model in to_read)) {
+ _.each(filter.filters, function (f) {
+ f.label = to_read[f.avatar_model][f.value];
+ });
+ }
+ if (filter.color_model && filter.field_color) {
+ _.each(filter.filters, function (f) {
+ if (!f.color_index) {
+ f.color_index = color_filter[filter.color_model] && color_filter[filter.color_model][f.value];
+ }
+ });
+ }
+ });
+ });
+ },
+ /**
+ * parse the server values to javascript framwork
+ *
+ * @private
+ * @param {Object} data the server data to parse
+ */
+ _parseServerData: function (data) {
+ var self = this;
+ _.each(data, function(event) {
+ _.each(self.fieldNames, function (fieldName) {
+ event[fieldName] = self._parseServerValue(self.fields[fieldName], event[fieldName]);
+ });
+ });
+ },
+ /**
+ * Transform OpenERP event object to fullcalendar event object
+ *
+ * @private
+ * @param {Object} evt
+ */
+ _recordToCalendarEvent: function (evt) {
+ var date_start;
+ var date_stop;
+ var date_delay = evt[this.mapping.date_delay] || 1.0,
+ all_day = this.fields[this.mapping.date_start].type === 'date' ||
+ this.mapping.all_day && evt[this.mapping.all_day] || false,
+ the_title = '',
+ attendees = [];
+
+ if (!all_day) {
+ date_start = evt[this.mapping.date_start].clone();
+ date_stop = this.mapping.date_stop ? evt[this.mapping.date_stop].clone() : null;
+ } else {
+ date_start = evt[this.mapping.date_start].clone().startOf('day');
+ date_stop = this.mapping.date_stop ? evt[this.mapping.date_stop].clone().startOf('day') : null;
+ }
+
+ if (!date_stop && date_delay) {
+ date_stop = date_start.clone().add(date_delay,'hours');
+ }
+
+ if (!all_day) {
+ date_start.add(this.getSession().getTZOffset(date_start), 'minutes');
+ date_stop.add(this.getSession().getTZOffset(date_stop), 'minutes');
+ }
+
+ if (this.mapping.all_day && evt[this.mapping.all_day]) {
+ date_stop.add(1, 'days');
+ }
+ var r = {
+ 'record': evt,
+ 'start': date_start.local(true).toDate(),
+ 'end': date_stop.local(true).toDate(),
+ 'r_start': date_start.clone().local(true).toDate(),
+ 'r_end': date_stop.clone().local(true).toDate(),
+ 'title': the_title,
+ 'allDay': all_day,
+ 'id': evt.id,
+ 'attendees':attendees,
+ };
+
+ if (!(this.mapping.all_day && evt[this.mapping.all_day]) && this.data.scale === 'month' && this.fields[this.mapping.date_start].type !== 'date') {
+ r.showTime = true;
+ }
+
+ return r;
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/calendar/calendar_popover.js b/addons/web/static/src/js/views/calendar/calendar_popover.js
new file mode 100644
index 00000000..18a3d1c2
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_popover.js
@@ -0,0 +1,220 @@
+odoo.define('web.CalendarPopover', function (require) {
+"use strict";
+
+var fieldRegistry = require('web.field_registry');
+const fieldRegistryOwl = require('web.field_registry_owl');
+const FieldWrapper = require('web.FieldWrapper');
+var StandaloneFieldManagerMixin = require('web.StandaloneFieldManagerMixin');
+var Widget = require('web.Widget');
+const { WidgetAdapterMixin } = require('web.OwlCompatibility');
+
+var CalendarPopover = Widget.extend(WidgetAdapterMixin, StandaloneFieldManagerMixin, {
+ template: 'CalendarView.event.popover',
+ events: {
+ 'click .o_cw_popover_edit': '_onClickPopoverEdit',
+ 'click .o_cw_popover_delete': '_onClickPopoverDelete',
+ },
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} eventInfo
+ */
+ init: function (parent, eventInfo) {
+ this._super.apply(this, arguments);
+ StandaloneFieldManagerMixin.init.call(this);
+ this.hideDate = eventInfo.hideDate;
+ this.hideTime = eventInfo.hideTime;
+ this.eventTime = eventInfo.eventTime;
+ this.eventDate = eventInfo.eventDate;
+ this.displayFields = eventInfo.displayFields;
+ this.fields = eventInfo.fields;
+ this.event = eventInfo.event;
+ this.modelName = eventInfo.modelName;
+ this._canDelete = eventInfo.canDelete;
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ return Promise.all([this._super.apply(this, arguments), this._processFields()]);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ _.each(this.$fieldsList, function ($field) {
+ $field.appendTo(self.$('.o_cw_popover_fields_secondary'));
+ });
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ WidgetAdapterMixin.destroy.call(this);
+ },
+ /**
+ * Called each time the widget is attached into the DOM.
+ */
+ on_attach_callback: function () {
+ WidgetAdapterMixin.on_attach_callback.call(this);
+ },
+ /**
+ * Called each time the widget is detached from the DOM.
+ */
+ on_detach_callback: function () {
+ WidgetAdapterMixin.on_detach_callback.call(this);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @return {boolean}
+ */
+ isEventDeletable() {
+ return this._canDelete;;
+ },
+ /**
+ * @return {boolean}
+ */
+ isEventDetailsVisible() {
+ return true;
+ },
+ /**
+ * @return {boolean}
+ */
+ isEventEditable() {
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Generate fields to render into popover
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _processFields: function () {
+ var self = this;
+ var fieldsToGenerate = [];
+ var fields = _.keys(this.displayFields);
+ for (var i=0; i<fields.length; i++) {
+ var fieldName = fields[i];
+ var displayFieldInfo = self.displayFields[fieldName] || {attrs: {invisible: 1}};
+ var fieldInfo = self.fields[fieldName];
+ var field = {
+ name: fieldName,
+ string: displayFieldInfo.attrs.string || fieldInfo.string,
+ value: self.event.extendedProps.record[fieldName],
+ type: fieldInfo.type,
+ invisible: displayFieldInfo.attrs.invisible,
+ };
+ if (field.type === 'selection') {
+ field.selection = fieldInfo.selection;
+ }
+ if (field.type === 'monetary') {
+ var currencyField = field.currency_field || 'currency_id';
+ if (!fields.includes(currencyField) && _.has(self.event.extendedProps.record, currencyField)) {
+ fields.push(currencyField);
+ }
+ }
+ if (fieldInfo.relation) {
+ field.relation = fieldInfo.relation;
+ }
+ if (displayFieldInfo.attrs.widget) {
+ field.widget = displayFieldInfo.attrs.widget;
+ } else if (_.contains(['many2many', 'one2many'], field.type)) {
+ field.widget = 'many2many_tags';
+ }
+ if (_.contains(['many2many', 'one2many'], field.type)) {
+ field.fields = [{
+ name: 'id',
+ type: 'integer',
+ }, {
+ name: 'display_name',
+ type: 'char',
+ }];
+ }
+ fieldsToGenerate.push(field);
+ };
+
+ this.$fieldsList = [];
+ return this.model.makeRecord(this.modelName, fieldsToGenerate).then(function (recordID) {
+ var defs = [];
+
+ var record = self.model.get(recordID);
+ _.each(fieldsToGenerate, function (field) {
+ if (field.invisible) return;
+ let isLegacy = true;
+ let fieldWidget;
+ let FieldClass = fieldRegistryOwl.getAny([field.widget, field.type]);
+ if (FieldClass) {
+ isLegacy = false;
+ fieldWidget = new FieldWrapper(this, FieldClass, {
+ fieldName: field.name,
+ record,
+ options: self.displayFields[field.name],
+ });
+ } else {
+ FieldClass = fieldRegistry.getAny([field.widget, field.type]);
+ fieldWidget = new FieldClass(self, field.name, record, self.displayFields[field.name]);
+ }
+ if (fieldWidget.attrs && !_.isObject(fieldWidget.attrs.modifiers)) {
+ fieldWidget.attrs.modifiers = fieldWidget.attrs.modifiers ? JSON.parse(fieldWidget.attrs.modifiers) : {};
+ }
+ self._registerWidget(recordID, field.name, fieldWidget);
+
+ var $field = $('<li>', {class: 'list-group-item flex-shrink-0 d-flex flex-wrap'});
+ var $fieldLabel = $('<strong>', {class: 'mr-2', text: _.str.sprintf('%s : ', field.string)});
+ $fieldLabel.appendTo($field);
+ var $fieldContainer = $('<div>', {class: 'flex-grow-1'});
+ $fieldContainer.appendTo($field);
+
+ let def;
+ if (isLegacy) {
+ def = fieldWidget.appendTo($fieldContainer);
+ } else {
+ def = fieldWidget.mount($fieldContainer[0]);
+ }
+ self.$fieldsList.push($field);
+ defs.push(def);
+ });
+ return Promise.all(defs);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onClickPopoverEdit: function (ev) {
+ ev.preventDefault();
+ this.trigger_up('edit_event', {
+ id: this.event.id,
+ title: this.event.extendedProps.record.display_name,
+ });
+ },
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onClickPopoverDelete: function (ev) {
+ ev.preventDefault();
+ this.trigger_up('delete_event', {id: this.event.id});
+ },
+});
+
+return CalendarPopover;
+
+});
diff --git a/addons/web/static/src/js/views/calendar/calendar_quick_create.js b/addons/web/static/src/js/views/calendar/calendar_quick_create.js
new file mode 100644
index 00000000..0f6f8bd6
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_quick_create.js
@@ -0,0 +1,114 @@
+odoo.define('web.CalendarQuickCreate', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+/**
+ * Quick creation view.
+ *
+ * Triggers a single event "added" with a single parameter "name", which is the
+ * name entered by the user
+ *
+ * @class
+ * @type {*}
+ */
+var QuickCreate = Dialog.extend({
+ events: _.extend({}, Dialog.events, {
+ 'keyup input': '_onkeyup',
+ }),
+
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} buttons
+ * @param {Object} options
+ * @param {Object} dataTemplate
+ * @param {Object} dataCalendar
+ */
+ init: function (parent, buttons, options, dataTemplate, dataCalendar) {
+ this._buttons = buttons || false;
+ this.options = options;
+
+ // Can hold data pre-set from where you clicked on agenda
+ this.dataTemplate = dataTemplate || {};
+ this.dataCalendar = dataCalendar;
+
+ var self = this;
+ this._super(parent, {
+ title: options.title,
+ size: 'small',
+ buttons: this._buttons ? [
+ {text: _t("Create"), classes: 'btn-primary', click: function () {
+ if (!self._quickAdd(dataCalendar)) {
+ self.focus();
+ }
+ }},
+ {text: _t("Edit"), click: function () {
+ dataCalendar.disableQuickCreate = true;
+ dataCalendar.title = self.$('input').val().trim();
+ dataCalendar.on_save = self.destroy.bind(self);
+ self.trigger_up('openCreate', dataCalendar);
+ }},
+ {text: _t("Cancel"), close: true},
+ ] : [],
+ $content: QWeb.render('CalendarView.quick_create', {widget: this})
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ focus: function () {
+ this.$('input').focus();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Gathers data from the quick create dialog a launch quick_create(data) method
+ */
+ _quickAdd: function (dataCalendar) {
+ dataCalendar = $.extend({}, this.dataTemplate, dataCalendar);
+ var val = this.$('input').val().trim();
+ if (!val) {
+ this.$('label, input').addClass('o_field_invalid');
+ var warnings = _.str.sprintf('<ul><li>%s</li></ul>', _t("Summary"));
+ this.do_warn(_t("Invalid fields:"), warnings);
+ }
+ dataCalendar.title = val;
+ return (val)? this.trigger_up('quickCreate', {data: dataCalendar, options: this.options}) : false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {keyEvent} event
+ */
+ _onkeyup: function (event) {
+ if (this._flagEnter) {
+ return;
+ }
+ if(event.keyCode === $.ui.keyCode.ENTER) {
+ this._flagEnter = true;
+ if (!this._quickAdd(this.dataCalendar)){
+ this._flagEnter = false;
+ }
+ } else if (event.keyCode === $.ui.keyCode.ESCAPE && this._buttons) {
+ this.close();
+ }
+ },
+});
+
+return QuickCreate;
+
+});
diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js
new file mode 100644
index 00000000..4ab750f6
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js
@@ -0,0 +1,1006 @@
+odoo.define('web.CalendarRenderer', function (require) {
+"use strict";
+
+var AbstractRenderer = require('web.AbstractRenderer');
+var CalendarPopover = require('web.CalendarPopover');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var field_utils = require('web.field_utils');
+var FieldManagerMixin = require('web.FieldManagerMixin');
+var relational_fields = require('web.relational_fields');
+var session = require('web.session');
+var Widget = require('web.Widget');
+const { createYearCalendarView } = require('/web/static/src/js/libs/fullcalendar.js');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var SidebarFilterM2O = relational_fields.FieldMany2One.extend({
+ _getSearchBlacklist: function () {
+ return this._super.apply(this, arguments).concat(this.filter_ids || []);
+ },
+});
+
+var SidebarFilter = Widget.extend(FieldManagerMixin, {
+ template: 'CalendarView.sidebar.filter',
+ custom_events: _.extend({}, FieldManagerMixin.custom_events, {
+ field_changed: '_onFieldChanged',
+ }),
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} options
+ * @param {string} options.fieldName
+ * @param {Object[]} options.filters A filter is an object with the
+ * following keys: id, value, label, active, avatar_model, color,
+ * can_be_removed
+ * @param {Object} [options.favorite] this is an object with the following
+ * keys: fieldName, model, fieldModel
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ FieldManagerMixin.init.call(this);
+
+ this.title = options.title;
+ this.fields = options.fields;
+ this.fieldName = options.fieldName;
+ this.write_model = options.write_model;
+ this.write_field = options.write_field;
+ this.avatar_field = options.avatar_field;
+ this.avatar_model = options.avatar_model;
+ this.filters = options.filters;
+ this.label = options.label;
+ this.getColor = options.getColor;
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ var defs = [this._super.apply(this, arguments)];
+
+ if (this.write_model || this.write_field) {
+ var def = this.model.makeRecord(this.write_model, [{
+ name: this.write_field,
+ relation: this.fields[this.fieldName].relation,
+ type: 'many2one',
+ }]).then(function (recordID) {
+ self.many2one = new SidebarFilterM2O(self,
+ self.write_field,
+ self.model.get(recordID),
+ {
+ mode: 'edit',
+ attrs: {
+ string: _t(self.fields[self.fieldName].string),
+ placeholder: "+ " + _.str.sprintf(_t("Add %s"), self.title),
+ can_create: false
+ },
+ });
+ });
+ defs.push(def);
+ }
+ return Promise.all(defs);
+
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this._super();
+ if (this.many2one) {
+ this.many2one.appendTo(this.$el);
+ this.many2one.filter_ids = _.without(_.pluck(this.filters, 'value'), 'all');
+ }
+ this.$el.on('click', '.o_remove', this._onFilterRemove.bind(this));
+ this.$el.on('click', '.o_calendar_filter_items input', this._onFilterActive.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onFieldChanged: function (event) {
+ var self = this;
+ event.stopPropagation();
+ var createValues = {'user_id': session.uid};
+ var value = event.data.changes[this.write_field].id;
+ createValues[this.write_field] = value;
+ this._rpc({
+ model: this.write_model,
+ method: 'create',
+ args: [createValues],
+ })
+ .then(function () {
+ self.trigger_up('changeFilter', {
+ 'fieldName': self.fieldName,
+ 'value': value,
+ 'active': true,
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onFilterActive: function (e) {
+ var $input = $(e.currentTarget);
+ this.trigger_up('changeFilter', {
+ 'fieldName': this.fieldName,
+ 'value': $input.closest('.o_calendar_filter_item').data('value'),
+ 'active': $input.prop('checked'),
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onFilterRemove: function (e) {
+ var self = this;
+ var $filter = $(e.currentTarget).closest('.o_calendar_filter_item');
+ Dialog.confirm(this, _t("Do you really want to delete this filter from favorites ?"), {
+ confirm_callback: function () {
+ self._rpc({
+ model: self.write_model,
+ method: 'unlink',
+ args: [[$filter.data('id')]],
+ })
+ .then(function () {
+ self.trigger_up('changeFilter', {
+ 'fieldName': self.fieldName,
+ 'id': $filter.data('id'),
+ 'active': false,
+ 'value': $filter.data('value'),
+ });
+ });
+ },
+ });
+ },
+});
+
+return AbstractRenderer.extend({
+ template: "CalendarView",
+ config: {
+ CalendarPopover: CalendarPopover,
+ },
+ custom_events: _.extend({}, AbstractRenderer.prototype.custom_events || {}, {
+ edit_event: '_onEditEvent',
+ delete_event: '_onDeleteEvent',
+ }),
+
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} state
+ * @param {Object} params
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this.displayFields = params.displayFields;
+ this.model = params.model;
+ this.filters = [];
+ this.color_map = {};
+ this.hideDate = params.hideDate;
+ this.hideTime = params.hideTime;
+ this.canDelete = params.canDelete;
+ this.canCreate = params.canCreate;
+ this.scalesInfo = params.scalesInfo;
+ this._isInDOM = false;
+ },
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ start: function () {
+ this._initSidebar();
+ this._initCalendar();
+ return this._super();
+ },
+ /**
+ * @override
+ */
+ on_attach_callback: function () {
+ this._super(...arguments);
+ this._isInDOM = true;
+ // BUG Test ????
+ // this.$el.height($(window).height() - this.$el.offset().top);
+ this.calendar.render();
+ this._renderCalendar();
+ window.addEventListener('click', this._onWindowClick.bind(this));
+ },
+ /**
+ * Called when the field is detached from the DOM.
+ */
+ on_detach_callback: function () {
+ this._super(...arguments);
+ this._isInDOM = false;
+ window.removeEventListener('click', this._onWindowClick);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this.calendar) {
+ this.calendar.destroy();
+ }
+ if (this.$small_calendar) {
+ this.$small_calendar.datepicker('destroy');
+ $('#ui-datepicker-div:empty').remove();
+ }
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Note: this is not dead code, it is called by the calendar-box template
+ *
+ * @param {any} record
+ * @param {any} fieldName
+ * @param {any} imageField
+ * @returns {string[]}
+ */
+ getAvatars: function (record, fieldName, imageField) {
+ var field = this.state.fields[fieldName];
+
+ if (!record[fieldName]) {
+ return [];
+ }
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ return _.map(record[fieldName], function (id) {
+ return '<img src="/web/image/'+field.relation+'/'+id+'/'+imageField+'" />';
+ });
+ } else if (field.type === 'many2one') {
+ return ['<img src="/web/image/'+field.relation+'/'+record[fieldName][0]+'/'+imageField+'" />'];
+ } else {
+ var value = this._format(record, fieldName);
+ var color = this.getColor(value);
+ if (isNaN(color)) {
+ return ['<span class="o_avatar_square" style="background-color:'+color+';"/>'];
+ }
+ else {
+ return ['<span class="o_avatar_square o_calendar_color_'+color+'"/>'];
+ }
+ }
+ },
+ /**
+ * Note: this is not dead code, it is called by two template
+ *
+ * @param {any} key
+ * @returns {integer}
+ */
+ getColor: function (key) {
+ if (!key) {
+ return;
+ }
+ if (this.color_map[key]) {
+ return this.color_map[key];
+ }
+ // check if the key is a css color
+ if (typeof key === 'string' && key.match(/^((#[A-F0-9]{3})|(#[A-F0-9]{6})|((hsl|rgb)a?\(\s*(?:(\s*\d{1,3}%?\s*),?){3}(\s*,[0-9.]{1,4})?\))|)$/i)) {
+ return this.color_map[key] = key;
+ }
+ if (typeof key === 'number' && !(key in this.color_map)) {
+ return this.color_map[key] = key;
+ }
+ var index = (((_.keys(this.color_map).length + 1) * 5) % 24) + 1;
+ this.color_map[key] = index;
+ return index;
+ },
+ /**
+ * @override
+ */
+ getLocalState: function () {
+ var fcScroller = this.calendarElement.querySelector('.fc-scroller');
+ return {
+ scrollPosition: fcScroller.scrollTop,
+ };
+ },
+ /**
+ * @override
+ */
+ setLocalState: function (localState) {
+ if (localState.scrollPosition) {
+ var fcScroller = this.calendarElement.querySelector('.fc-scroller');
+ fcScroller.scrollTop = localState.scrollPosition;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Convert the new format of Event from FullCalendar V4 to a Event FullCalendar V3
+ * @param fc4Event
+ * @return {Object} FullCalendar V3 Object Event
+ * @private
+ */
+ _convertEventToFC3Event: function (fc4Event) {
+ var event = fc4Event;
+ if (!moment.isMoment(fc4Event.start)) {
+ event = {
+ id: fc4Event.id,
+ title: fc4Event.title,
+ start: moment(fc4Event.start).utcOffset(0, true),
+ end: fc4Event.end && moment(fc4Event.end).utcOffset(0, true),
+ allDay: fc4Event.allDay,
+ color: fc4Event.color,
+ };
+ if (fc4Event.extendedProps) {
+ event = Object.assign({}, event, {
+ r_start: fc4Event.extendedProps.r_start && moment(fc4Event.extendedProps.r_start).utcOffset(0, true),
+ r_end: fc4Event.extendedProps.r_end && moment(fc4Event.extendedProps.r_end).utcOffset(0, true),
+ record: fc4Event.extendedProps.record,
+ attendees: fc4Event.extendedProps.attendees,
+ });
+ }
+ }
+ return event;
+ },
+ /**
+ * @param {any} event
+ * @returns {string} the html for the rendered event
+ */
+ _eventRender: function (event) {
+ var qweb_context = {
+ event: event,
+ record: event.extendedProps.record,
+ color: this.getColor(event.extendedProps.color_index),
+ showTime: !self.hideTime && event.extendedProps.showTime,
+ };
+ this.qweb_context = qweb_context;
+ if (_.isEmpty(qweb_context.record)) {
+ return '';
+ } else {
+ return qweb.render("calendar-box", qweb_context);
+ }
+ },
+ /**
+ * @private
+ * @param {any} record
+ * @param {any} fieldName
+ * @returns {string}
+ */
+ _format: function (record, fieldName) {
+ var field = this.state.fields[fieldName];
+ if (field.type === "one2many" || field.type === "many2many") {
+ return field_utils.format[field.type]({data: record[fieldName]}, field);
+ } else {
+ return field_utils.format[field.type](record[fieldName], field, {forceString: true});
+ }
+ },
+ /**
+ * Return the Object options for FullCalendar
+ *
+ * @private
+ * @param {Object} fcOptions
+ * @return {Object}
+ */
+ _getFullCalendarOptions: function (fcOptions) {
+ var self = this;
+ const options = Object.assign({}, this.state.fc_options, {
+ plugins: [
+ 'moment',
+ 'interaction',
+ 'dayGrid',
+ 'timeGrid'
+ ],
+ eventDrop: function (eventDropInfo) {
+ var event = self._convertEventToFC3Event(eventDropInfo.event);
+ self.trigger_up('dropRecord', event);
+ },
+ eventResize: function (eventResizeInfo) {
+ self._unselectEvent();
+ var event = self._convertEventToFC3Event(eventResizeInfo.event);
+ self.trigger_up('updateRecord', event);
+ },
+ eventClick: function (eventClickInfo) {
+ eventClickInfo.jsEvent.preventDefault();
+ eventClickInfo.jsEvent.stopPropagation();
+ var eventData = eventClickInfo.event;
+ self._unselectEvent();
+ $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', eventData.id)).addClass('o_cw_custom_highlight');
+ self._renderEventPopover(eventData, $(eventClickInfo.el));
+ },
+ yearDateClick: function (info) {
+ self._unselectEvent();
+ info.view.unselect();
+ if (!info.events.length) {
+ if (info.selectable) {
+ const data = {
+ start: info.date,
+ allDay: true,
+ };
+ if (self.state.context.default_name) {
+ data.title = self.state.context.default_name;
+ }
+ self.trigger_up('openCreate', self._convertEventToFC3Event(data));
+ }
+ } else {
+ self._renderYearEventPopover(info.date, info.events, $(info.dayEl));
+ }
+ },
+ select: function (selectionInfo) {
+ // Clicking on the view, dispose any visible popover. Otherwise create a new event.
+ if (self.$('.o_cw_popover').length) {
+ self._unselectEvent();
+ }
+ var data = {start: selectionInfo.start, end: selectionInfo.end, allDay: selectionInfo.allDay};
+ if (self.state.context.default_name) {
+ data.title = self.state.context.default_name;
+ }
+ self.trigger_up('openCreate', self._convertEventToFC3Event(data));
+ if (self.state.scale === 'year') {
+ self.calendar.view.unselect();
+ } else {
+ self.calendar.unselect();
+ }
+ },
+ eventRender: function (info) {
+ var event = info.event;
+ var element = $(info.el);
+ var view = info.view;
+ element.attr('data-event-id', event.id);
+ if (view.type === 'dayGridYear') {
+ const color = this.getColor(event.extendedProps.color_index);
+ if (typeof color === 'string') {
+ element.css({
+ backgroundColor: color,
+ });
+ } else if (typeof color === 'number') {
+ element.addClass(`o_calendar_color_${color}`);
+ } else {
+ element.addClass('o_calendar_color_1');
+ }
+ } else {
+ var $render = $(self._eventRender(event));
+ element.find('.fc-content').html($render.html());
+ element.addClass($render.attr('class'));
+
+ // Add background if doesn't exist
+ if (!element.find('.fc-bg').length) {
+ element.find('.fc-content').after($('<div/>', {class: 'fc-bg'}));
+ }
+
+ if (view.type === 'dayGridMonth' && event.extendedProps.record) {
+ var start = event.extendedProps.r_start || event.start;
+ var end = event.extendedProps.r_end || event.end;
+ // Detect if the event occurs in just one day
+ // note: add & remove 1 min to avoid issues with 00:00
+ var isSameDayEvent = moment(start).clone().add(1, 'minute').isSame(moment(end).clone().subtract(1, 'minute'), 'day');
+ if (!event.extendedProps.record.allday && isSameDayEvent) {
+ // For month view: do not show background for non allday, single day events
+ element.addClass('o_cw_nobg');
+ if (event.extendedProps.showTime && !self.hideTime) {
+ const displayTime = moment(start).clone().format(self._getDbTimeFormat());
+ element.find('.fc-content .fc-time').text(displayTime);
+ }
+ }
+ }
+
+ // On double click, edit the event
+ element.on('dblclick', function () {
+ self.trigger_up('edit_event', {id: event.id});
+ });
+ }
+ },
+ datesRender: function (info) {
+ const viewToMode = Object.fromEntries(
+ Object.entries(self.scalesInfo).map(([k, v]) => [v, k])
+ );
+ self.trigger_up('viewUpdated', {
+ mode: viewToMode[info.view.type],
+ title: info.view.title,
+ });
+ },
+ // Add/Remove a class on hover to style multiple days events.
+ // The css ":hover" selector can't be used because these events
+ // are rendered using multiple elements.
+ eventMouseEnter: function (mouseEnterInfo) {
+ $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseEnterInfo.event.id)).addClass('o_cw_custom_hover');
+ },
+ eventMouseLeave: function (mouseLeaveInfo) {
+ if (!mouseLeaveInfo.event.id) {
+ return;
+ }
+ $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseLeaveInfo.event.id)).removeClass('o_cw_custom_hover');
+ },
+ eventDragStart: function (mouseDragInfo) {
+ mouseDragInfo.el.classList.add(mouseDragInfo.view.type);
+ $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseDragInfo.event.id)).addClass('o_cw_custom_hover');
+ self._unselectEvent();
+ },
+ eventResizeStart: function (mouseResizeInfo) {
+ $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseResizeInfo.event.id)).addClass('o_cw_custom_hover');
+ self._unselectEvent();
+ },
+ eventLimitClick: function () {
+ self._unselectEvent();
+ return 'popover';
+ },
+ windowResize: function () {
+ self._render();
+ },
+ views: {
+ timeGridDay: {
+ columnHeaderFormat: 'LL'
+ },
+ timeGridWeek: {
+ columnHeaderFormat: 'ddd D'
+ },
+ dayGridMonth: {
+ columnHeaderFormat: 'dddd'
+ }
+ },
+ height: 'parent',
+ unselectAuto: false,
+ dir: _t.database.parameters.direction,
+ events: (info, successCB) => {
+ successCB(self.state.data);
+ },
+ }, fcOptions);
+ options.plugins.push(createYearCalendarView(FullCalendar, options));
+ return options;
+ },
+ /**
+ * Initialize the main calendar
+ *
+ * @private
+ */
+ _initCalendar: function () {
+ this.calendarElement = this.$(".o_calendar_widget")[0];
+ var locale = moment.locale();
+
+ var fcOptions = this._getFullCalendarOptions({
+ locale: locale, // reset locale when fullcalendar has already been instanciated before now
+ });
+
+ this.calendar = new FullCalendar.Calendar(this.calendarElement, fcOptions);
+ },
+ /**
+ * Initialize the mini calendar in the sidebar
+ *
+ * @private
+ */
+ _initCalendarMini: function () {
+ var self = this;
+ this.$small_calendar = this.$(".o_calendar_mini");
+ this.$small_calendar.datepicker({
+ 'onSelect': function (datum, obj) {
+ self.trigger_up('changeDate', {
+ date: moment(new Date(+obj.currentYear , +obj.currentMonth, +obj.currentDay))
+ });
+ },
+ 'showOtherMonths': true,
+ 'dayNamesMin' : this.state.fc_options.dayNamesShort.map(x => x[0]),
+ 'monthNames': this.state.fc_options.monthNamesShort,
+ 'firstDay': this.state.fc_options.firstDay,
+ });
+ },
+ /**
+ * Initialize the sidebar
+ *
+ * @private
+ */
+ _initSidebar: function () {
+ this.$sidebar = this.$('.o_calendar_sidebar');
+ this.$sidebar_container = this.$(".o_calendar_sidebar_container");
+ this._initCalendarMini();
+ },
+ /**
+ * Finalise the popover
+ *
+ * @param {jQueryElement} $popoverElement
+ * @param {web.CalendarPopover} calendarPopover
+ * @private
+ */
+ _onPopoverShown: function ($popoverElement, calendarPopover) {
+ var $popover = $($popoverElement.data('bs.popover').tip);
+ $popover.find('.o_cw_popover_close').on('click', this._unselectEvent.bind(this));
+ $popover.find('.o_cw_body').replaceWith(calendarPopover.$el);
+ },
+ /**
+ * Render the calendar view, this is the main entry point.
+ *
+ * @override
+ */
+ async _renderView() {
+ this.$('.o_calendar_view')[0].prepend(this.calendarElement);
+ if (this._isInDOM) {
+ this._renderCalendar();
+ }
+ this.$small_calendar.datepicker("setDate", this.state.highlight_date.toDate())
+ .find('.o_selected_range')
+ .removeClass('o_color o_selected_range');
+ var $a;
+ switch (this.state.scale) {
+ case 'year': $a = this.$small_calendar.find('td'); break;
+ case 'month': $a = this.$small_calendar.find('td'); break;
+ case 'week': $a = this.$small_calendar.find('tr:has(.ui-state-active)'); break;
+ case 'day': $a = this.$small_calendar.find('a.ui-state-active'); break;
+ }
+ $a.addClass('o_selected_range');
+ setTimeout(function () {
+ $a.not('.ui-state-active').addClass('o_color');
+ });
+
+ await this._renderFilters();
+ },
+ /**
+ * Render the specific code for the FullCalendar when it's in the DOM
+ *
+ * @private
+ */
+ _renderCalendar() {
+ this.calendar.unselect();
+
+ if (this.scalesInfo[this.state.scale] !== this.calendar.view.type) {
+ this.calendar.changeView(this.scalesInfo[this.state.scale]);
+ }
+
+ if (this.target_date !== this.state.target_date.toString()) {
+ this.calendar.gotoDate(moment(this.state.target_date).toDate());
+ this.target_date = this.state.target_date.toString();
+ } else {
+ // this.calendar.gotoDate already renders events when called
+ // so render events only when domain changes
+ this._renderEvents();
+ }
+
+ this._unselectEvent();
+ // this._scrollToScrollTime();
+ },
+ /**
+ * Render all events
+ *
+ * @private
+ */
+ _renderEvents: function () {
+ this.calendar.refetchEvents();
+ },
+ /**
+ * Render all filters
+ *
+ * @private
+ * @returns {Promise} resolved when all filters have been rendered
+ */
+ _renderFilters: function () {
+ // Dispose of filter popover
+ this.$('.o_calendar_filter_item').popover('dispose');
+ _.each(this.filters || (this.filters = []), function (filter) {
+ filter.destroy();
+ });
+ if (this.state.fullWidth) {
+ return Promise.resolve();
+ }
+ return this._renderFiltersOneByOne();
+ },
+ /**
+ * Renders each filter one by one, waiting for the first filter finished to
+ * be rendered and appended to render the next one.
+ * We need to do like this since render a filter is asynchronous, we don't
+ * know which one will be appened at first and we want tp force them to be
+ * rendered in order.
+ *
+ * @param {number} filterIndex if not set, 0 by default
+ * @returns {Promise} resolved when all filters have been rendered
+ */
+ _renderFiltersOneByOne: function (filterIndex) {
+ filterIndex = filterIndex || 0;
+ var arrFilters = _.toArray(this.state.filters);
+ var prom;
+ if (filterIndex < arrFilters.length) {
+ var options = arrFilters[filterIndex];
+ if (!_.find(options.filters, function (f) {return f.display == null || f.display;})) {
+ return this._renderFiltersOneByOne(filterIndex + 1);
+ }
+
+ var self = this;
+ options.getColor = this.getColor.bind(this);
+ options.fields = this.state.fields;
+ var sidebarFilter = new SidebarFilter(self, options);
+ prom = sidebarFilter.appendTo(this.$sidebar).then(function () {
+ // Show filter popover
+ if (options.avatar_field) {
+ _.each(options.filters, function (filter) {
+ if (!['all', false].includes(filter.value)) {
+ var selector = _.str.sprintf('.o_calendar_filter_item[data-value=%s]', filter.value);
+ sidebarFilter.$el.find(selector).popover({
+ animation: false,
+ trigger: 'hover',
+ html: true,
+ placement: 'top',
+ title: filter.label,
+ delay: {show: 300, hide: 0},
+ content: function () {
+ return $('<img>', {
+ src: _.str.sprintf('/web/image/%s/%s/%s', options.avatar_model, filter.value, options.avatar_field),
+ class: 'mx-auto',
+ });
+ },
+ });
+ }
+ });
+ }
+ return self._renderFiltersOneByOne(filterIndex + 1);
+ });
+ this.filters.push(sidebarFilter);
+ }
+ return Promise.resolve(prom);
+ },
+ /**
+ * Returns the time format from database parameters (only hours and minutes).
+ * FIXME: this looks like a weak heuristic...
+ *
+ * @private
+ * @returns {string}
+ */
+ _getDbTimeFormat: function () {
+ return _t.database.parameters.time_format.search('%H') !== -1 ? 'HH:mm' : 'hh:mm a';
+ },
+ /**
+ * Returns event's formatted date for popovers.
+ *
+ * @private
+ * @param {moment} start
+ * @param {moment} end
+ * @param {boolean} showDayName
+ * @param {boolean} allDay
+ */
+ _getFormattedDate: function (start, end, showDayName, allDay) {
+ const isSameDayEvent = start.clone().add(1, 'minute')
+ .isSame(end.clone().subtract(1, 'minute'), 'day');
+ if (allDay) {
+ // cancel correction done in _recordToCalendarEvent
+ end = end.clone().subtract(1, 'day');
+ }
+ if (!isSameDayEvent && start.isSame(end, 'month')) {
+ // Simplify date-range if an event occurs into the same month (eg. '4-5 August 2019')
+ return start.clone().format('MMMM D') + '-' + end.clone().format('D, YYYY');
+ } else {
+ return isSameDayEvent ?
+ start.clone().format(showDayName ? 'dddd, LL' : 'LL') :
+ start.clone().format('LL') + ' - ' + end.clone().format('LL');
+ }
+ },
+ /**
+ * Prepare context to display in the popover.
+ *
+ * @private
+ * @param {Object} eventData
+ * @returns {Object} context
+ */
+ _getPopoverContext: function (eventData) {
+ var context = {
+ hideDate: this.hideDate,
+ hideTime: this.hideTime,
+ eventTime: {},
+ eventDate: {},
+ fields: this.state.fields,
+ displayFields: this.displayFields,
+ event: eventData,
+ modelName: this.model,
+ canDelete: this.canDelete,
+ };
+
+ var start = moment((eventData.extendedProps && eventData.extendedProps.r_start) || eventData.start);
+ var end = moment((eventData.extendedProps && eventData.extendedProps.r_end) || eventData.end);
+ var isSameDayEvent = start.clone().add(1, 'minute').isSame(end.clone().subtract(1, 'minute'), 'day');
+
+ // Do not display timing if the event occur across multiple days. Otherwise use user's timing preferences
+ if (!this.hideTime && !eventData.extendedProps.record.allday && isSameDayEvent) {
+ var dbTimeFormat = this._getDbTimeFormat();
+
+ context.eventTime.time = start.clone().format(dbTimeFormat) + ' - ' + end.clone().format(dbTimeFormat);
+
+ // Calculate duration and format text
+ var durationHours = moment.duration(end.diff(start)).hours();
+ var durationHoursKey = (durationHours === 1) ? 'h' : 'hh';
+ var durationMinutes = moment.duration(end.diff(start)).minutes();
+ var durationMinutesKey = (durationMinutes === 1) ? 'm' : 'mm';
+
+ var localeData = moment.localeData(); // i18n for 'hours' and "minutes" strings
+ context.eventTime.duration = (durationHours > 0 ? localeData.relativeTime(durationHours, true, durationHoursKey) : '')
+ + (durationHours > 0 && durationMinutes > 0 ? ', ' : '')
+ + (durationMinutes > 0 ? localeData.relativeTime(durationMinutes, true, durationMinutesKey) : '');
+ }
+
+ if (!this.hideDate) {
+
+ if (eventData.extendedProps.record.allday && isSameDayEvent) {
+ context.eventDate.duration = _t("All day");
+ } else if (eventData.extendedProps.record.allday && !isSameDayEvent) {
+ var daysLocaleData = moment.localeData();
+ var days = moment.duration(end.diff(start)).days();
+ context.eventDate.duration = daysLocaleData.relativeTime(days, true, 'dd');
+ }
+
+ context.eventDate.date = this._getFormattedDate(start, end, true, eventData.extendedProps.record.allday);
+ }
+
+ return context;
+ },
+ /**
+ * Prepare the parameters for the popover.
+ * This allow the parameters to be extensible.
+ *
+ * @private
+ * @param {Object} eventData
+ */
+ _getPopoverParams: function (eventData) {
+ return {
+ animation: false,
+ delay: {
+ show: 50,
+ hide: 100
+ },
+ trigger: 'manual',
+ html: true,
+ title: eventData.extendedProps.record.display_name,
+ template: qweb.render('CalendarView.event.popover.placeholder', {color: this.getColor(eventData.extendedProps.color_index)}),
+ container: eventData.allDay ? '.fc-view' : '.fc-scroller',
+ }
+ },
+ /**
+ * Render event popover
+ *
+ * @private
+ * @param {Object} eventData
+ * @param {jQueryElement} $eventElement
+ */
+ _renderEventPopover: function (eventData, $eventElement) {
+ var self = this;
+
+ // Initialize popover widget
+ var calendarPopover = new self.config.CalendarPopover(self, self._getPopoverContext(eventData));
+ calendarPopover.appendTo($('<div>')).then(() => {
+ $eventElement.popover(
+ self._getPopoverParams(eventData)
+ ).on('shown.bs.popover', function () {
+ self._onPopoverShown($(this), calendarPopover);
+ }).popover('show');
+ });
+ },
+ /**
+ * Render year event popover
+ *
+ * @private
+ * @param {Date} date
+ * @param {Object[]} events
+ * @param {jQueryElement} $el
+ */
+ _renderYearEventPopover: function (date, events, $el) {
+ const groupKeys = [];
+ const groupedEvents = {};
+ for (const event of events) {
+ const start = moment(event.extendedProps.r_start);
+ const end = moment(event.extendedProps.r_end);
+ const key = this._getFormattedDate(start, end, false, event.extendedProps.record.allday);
+ if (!(key in groupedEvents)) {
+ groupedEvents[key] = [];
+ groupKeys.push({
+ key: key,
+ start: event.extendedProps.r_start,
+ end: event.extendedProps.r_end,
+ isSameDayEvent: start.clone().add(1, 'minute')
+ .isSame(end.clone().subtract(1, 'minute'), 'day'),
+ });
+ }
+ groupedEvents[key].push(event);
+ }
+
+ const popoverContent = qweb.render('CalendarView.yearEvent.popover', {
+ groupedEvents,
+ groupKeys: groupKeys
+ .sort((a, b) => {
+ if (a.isSameDayEvent) {
+ // if isSameDayEvent then put it before the others
+ return Number.MIN_SAFE_INTEGER;
+ } else if (b.isSameDayEvent) {
+ return Number.MAX_SAFE_INTEGER;
+ } else if (a.start.getTime() - b.start.getTime() === 0) {
+ return a.end.getTime() - b.end.getTime();
+ }
+ return a.start.getTime() - b.start.getTime();
+ })
+ .map(x => x.key),
+ canCreate: this.canCreate,
+ });
+
+ $el.popover({
+ animation: false,
+ delay: {
+ show: 50,
+ hide: 100
+ },
+ trigger: 'manual',
+ html: true,
+ content: popoverContent,
+ template: qweb.render('CalendarView.yearEvent.popover.placeholder'),
+ container: '.fc-dayGridYear-view',
+ }).on('shown.bs.popover', () => {
+ $('.o_cw_popover .o_cw_popover_close').on('click', () => this._unselectEvent());
+ $('.o_cw_popover .o_cw_popover_create').on('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this._unselectEvent();
+ const data = {
+ start: date,
+ allDay: true,
+ };
+ if (this.state.context.default_name) {
+ data.title = this.state.context.default_name;
+ }
+ this.trigger_up('openCreate', this._convertEventToFC3Event(data));
+ });
+ $('.o_cw_popover .o_cw_popover_link').on('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this._unselectEvent();
+ this.trigger_up('openEvent', {
+ _id: parseInt(e.target.dataset.id, 10),
+ title: e.target.dataset.title,
+ });
+ });
+ }).popover('show');
+ },
+ /**
+ * Scroll to the time set in the FullCalendar parameter
+ * @private
+ */
+ _scrollToScrollTime: function () {
+ var scrollTime = this.calendar.getOption('scrollTime');
+ this.calendar.scrollToTime(scrollTime);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Remove highlight classes and dispose of popovers
+ *
+ * @private
+ */
+ _unselectEvent: function () {
+ this.$('.fc-event').removeClass('o_cw_custom_highlight');
+ this.$('.o_cw_popover').popover('dispose');
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onWindowClick: function (e) {
+ const popover = this.el.querySelector('.o_cw_popover');
+ if (popover && !popover.contains(e.target)) {
+ this._unselectEvent();
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onEditEvent: function (event) {
+ this._unselectEvent();
+ this.trigger_up('openEvent', {
+ _id: event.data.id,
+ title: event.data.title,
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onDeleteEvent: function (event) {
+ this._unselectEvent();
+ this.trigger_up('deleteRecord', {id: parseInt(event.data.id, 10)});
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js
new file mode 100644
index 00000000..b83b8072
--- /dev/null
+++ b/addons/web/static/src/js/views/calendar/calendar_view.js
@@ -0,0 +1,204 @@
+odoo.define('web.CalendarView', function (require) {
+"use strict";
+
+var AbstractView = require('web.AbstractView');
+var CalendarModel = require('web.CalendarModel');
+var CalendarController = require('web.CalendarController');
+var CalendarRenderer = require('web.CalendarRenderer');
+var core = require('web.core');
+var pyUtils = require('web.py_utils');
+var utils = require('web.utils');
+
+var _lt = core._lt;
+
+// gather the fields to get
+var fieldsToGather = [
+ "date_start",
+ "date_delay",
+ "date_stop",
+ "all_day",
+ "recurrence_update"
+];
+
+const scalesInfo = {
+ day: 'timeGridDay',
+ week: 'timeGridWeek',
+ month: 'dayGridMonth',
+ year: 'dayGridYear',
+};
+
+var CalendarView = AbstractView.extend({
+ display_name: _lt('Calendar'),
+ icon: 'fa-calendar',
+ jsLibs: [
+ '/web/static/lib/fullcalendar/core/main.js',
+ '/web/static/lib/fullcalendar/interaction/main.js',
+ '/web/static/lib/fullcalendar/moment/main.js',
+ '/web/static/lib/fullcalendar/daygrid/main.js',
+ '/web/static/lib/fullcalendar/timegrid/main.js',
+ '/web/static/lib/fullcalendar/list/main.js'
+ ],
+ cssLibs: [
+ '/web/static/lib/fullcalendar/core/main.css',
+ '/web/static/lib/fullcalendar/daygrid/main.css',
+ '/web/static/lib/fullcalendar/timegrid/main.css',
+ '/web/static/lib/fullcalendar/list/main.css'
+ ],
+ config: _.extend({}, AbstractView.prototype.config, {
+ Model: CalendarModel,
+ Controller: CalendarController,
+ Renderer: CalendarRenderer,
+ }),
+ viewType: 'calendar',
+ searchMenuTypes: ['filter', 'favorite'],
+
+ /**
+ * @override
+ */
+ init: function (viewInfo, params) {
+ this._super.apply(this, arguments);
+ var arch = this.arch;
+ var fields = this.fields;
+ var attrs = arch.attrs;
+
+ if (!attrs.date_start) {
+ throw new Error(_lt("Calendar view has not defined 'date_start' attribute."));
+ }
+
+ var mapping = {};
+ var fieldNames = fields.display_name ? ['display_name'] : [];
+ var displayFields = {};
+
+ _.each(fieldsToGather, function (field) {
+ if (arch.attrs[field]) {
+ var fieldName = attrs[field];
+ mapping[field] = fieldName;
+ fieldNames.push(fieldName);
+ }
+ });
+
+ var filters = {};
+
+ var eventLimit = attrs.event_limit !== null && (isNaN(+attrs.event_limit) ? _.str.toBool(attrs.event_limit) : +attrs.event_limit);
+
+ var modelFilters = [];
+ _.each(arch.children, function (child) {
+ if (child.tag !== 'field') return;
+ var fieldName = child.attrs.name;
+ fieldNames.push(fieldName);
+ if (!child.attrs.invisible || child.attrs.filters) {
+ child.attrs.options = child.attrs.options ? pyUtils.py_eval(child.attrs.options) : {};
+ if (!child.attrs.invisible) {
+ displayFields[fieldName] = {attrs: child.attrs};
+ }
+
+ if (params.sidebar === false) return; // if we have not sidebar, (eg: Dashboard), we don't use the filter "coworkers"
+
+ if (child.attrs.avatar_field) {
+ filters[fieldName] = filters[fieldName] || {
+ 'title': fields[fieldName].string,
+ 'fieldName': fieldName,
+ 'filters': [],
+ };
+ filters[fieldName].avatar_field = child.attrs.avatar_field;
+ filters[fieldName].avatar_model = fields[fieldName].relation;
+ }
+ if (child.attrs.write_model) {
+ filters[fieldName] = filters[fieldName] || {
+ 'title': fields[fieldName].string,
+ 'fieldName': fieldName,
+ 'filters': [],
+ };
+ filters[fieldName].write_model = child.attrs.write_model;
+ filters[fieldName].write_field = child.attrs.write_field; // can't use a x2many fields
+
+ modelFilters.push(fields[fieldName].relation);
+ }
+ if (child.attrs.filters) {
+ filters[fieldName] = filters[fieldName] || {
+ 'title': fields[fieldName].string,
+ 'fieldName': fieldName,
+ 'filters': [],
+ };
+ if (child.attrs.color) {
+ filters[fieldName].field_color = child.attrs.color;
+ filters[fieldName].color_model = fields[fieldName].relation;
+ }
+ if (!child.attrs.avatar_field && fields[fieldName].relation) {
+ if (fields[fieldName].relation.includes(['res.users', 'res.partner', 'hr.employee'])) {
+ filters[fieldName].avatar_field = 'image_128';
+ }
+ filters[fieldName].avatar_model = fields[fieldName].relation;
+ }
+ }
+ }
+ });
+
+ if (attrs.color) {
+ var fieldName = attrs.color;
+ fieldNames.push(fieldName);
+ }
+
+ //if quick_add = False, we don't allow quick_add
+ //if quick_add = not specified in view, we use the default widgets.QuickCreate
+ //if quick_add = is NOT False and IS specified in view, we this one for widgets.QuickCreate'
+ this.controllerParams.quickAddPop = (!('quick_add' in attrs) || utils.toBoolElse(attrs.quick_add+'', true));
+ this.controllerParams.disableQuickCreate = params.disable_quick_create || !this.controllerParams.quickAddPop;
+
+ // If form_view_id is set, then the calendar view will open a form view
+ // with this id, when it needs to edit or create an event.
+ this.controllerParams.formViewId =
+ attrs.form_view_id ? parseInt(attrs.form_view_id, 10) : false;
+ if (!this.controllerParams.formViewId && params.action) {
+ var formViewDescr = _.find(params.action.views, function (v) {
+ return v.type === 'form';
+ });
+ if (formViewDescr) {
+ this.controllerParams.formViewId = formViewDescr.viewID;
+ }
+ }
+
+ let scales;
+ const allowedScales = Object.keys(scalesInfo);
+ if (arch.attrs.scales) {
+ scales = arch.attrs.scales.split(',')
+ .filter(x => allowedScales.includes(x));
+ } else {
+ scales = allowedScales;
+ }
+
+ this.controllerParams.eventOpenPopup = utils.toBoolElse(attrs.event_open_popup || '', false);
+ this.controllerParams.showUnusualDays = utils.toBoolElse(attrs.show_unusual_days || '', false);
+ this.controllerParams.mapping = mapping;
+ this.controllerParams.context = params.context || {};
+ this.controllerParams.displayName = params.action && params.action.name;
+ this.controllerParams.scales = scales;
+
+ this.rendererParams.displayFields = displayFields;
+ this.rendererParams.model = viewInfo.model;
+ this.rendererParams.hideDate = utils.toBoolElse(attrs.hide_date || '', false);
+ this.rendererParams.hideTime = utils.toBoolElse(attrs.hide_time || '', false);
+ this.rendererParams.canDelete = this.controllerParams.activeActions.delete;
+ this.rendererParams.canCreate = this.controllerParams.activeActions.create;
+ this.rendererParams.scalesInfo = scalesInfo;
+
+ this.loadParams.fieldNames = _.uniq(fieldNames);
+ this.loadParams.mapping = mapping;
+ this.loadParams.fields = fields;
+ this.loadParams.fieldsInfo = viewInfo.fieldsInfo;
+ this.loadParams.editable = !fields[mapping.date_start].readonly;
+ this.loadParams.creatable = this.controllerParams.activeActions.create;
+ this.loadParams.eventLimit = eventLimit;
+ this.loadParams.fieldColor = attrs.color;
+
+ this.loadParams.filters = filters;
+ this.loadParams.mode = (params.context && params.context.default_mode) || attrs.mode;
+ this.loadParams.scales = scales;
+ this.loadParams.initialDate = moment(params.initialDate || new Date());
+ this.loadParams.scalesInfo = scalesInfo;
+ },
+});
+
+return CalendarView;
+
+});
diff --git a/addons/web/static/src/js/views/field_manager_mixin.js b/addons/web/static/src/js/views/field_manager_mixin.js
new file mode 100644
index 00000000..aab8dd14
--- /dev/null
+++ b/addons/web/static/src/js/views/field_manager_mixin.js
@@ -0,0 +1,166 @@
+odoo.define('web.FieldManagerMixin', function (require) {
+"use strict";
+
+/**
+ * The FieldManagerMixin is a mixin, designed to do the plumbing between field
+ * widgets and a basicmodel. Field widgets can be used outside of a view. In
+ * that case, someone needs to listen to events bubbling up from the widgets and
+ * calling the correct methods on the model. This is the field_manager's job.
+ */
+
+var BasicModel = require('web.BasicModel');
+var concurrency = require('web.concurrency');
+
+var FieldManagerMixin = {
+ custom_events: {
+ field_changed: '_onFieldChanged',
+ load: '_onLoad',
+ mutexify: '_onMutexify',
+ },
+ /**
+ * A FieldManagerMixin can be initialized with an instance of a basicModel.
+ * If not, it will simply uses its own.
+ *
+ * @param {BasicModel} [model]
+ */
+ init: function (model) {
+ this.model = model || new BasicModel(this);
+ this.mutex = new concurrency.Mutex();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Apply changes by notifying the basic model, then saving the data if
+ * necessary, and finally, confirming the changes to the UI.
+ *
+ * @todo find a way to remove ugly 3rd argument...
+ *
+ * @param {string} dataPointID
+ * @param {Object} changes
+ * @param {OdooEvent} event
+ * @returns {Promise} resolves when the change has been done, and the UI
+ * updated
+ */
+ _applyChanges: function (dataPointID, changes, event) {
+ var self = this;
+ var options = _.pick(event.data, 'context', 'doNotSetDirty', 'notifyChange', 'viewType', 'allowWarning');
+ return this.model.notifyChanges(dataPointID, changes, options)
+ .then(function (result) {
+ if (event.data.force_save) {
+ return self.model.save(dataPointID).then(function () {
+ return self._confirmSave(dataPointID);
+ }).guardedCatch(function () {
+ return self._rejectSave(dataPointID);
+ });
+ } else if (options.notifyChange !== false) {
+ return self._confirmChange(dataPointID, result, event);
+ }
+ });
+ },
+ /**
+ * This method will be called whenever a field value has changed (and has
+ * been confirmed by the model).
+ *
+ * @abstract
+ * @param {string} id basicModel Id for the changed record
+ * @param {string[]} fields the fields (names) that have been changed
+ * @param {OdooEvent} event the event that triggered the change
+ * @returns {Promise}
+ */
+ _confirmChange: function (id, fields, event) {
+ return Promise.resolve();
+ },
+ /**
+ * This method will be called whenever a save has been triggered by a change
+ * in some controlled field value. For example, when a priority widget is
+ * being changed in a readonly form.
+ *
+ * @see _onFieldChanged
+ * @abstract
+ * @param {string} id The basicModel ID for the saved record
+ * @returns {Promise}
+ */
+ _confirmSave: function (id) {
+ return Promise.resolve();
+ },
+ /**
+ * This method will be called whenever a save has been triggered by a change
+ * and has failed. For example, when a statusbar button is clicked in a
+ * readonly form view.
+ *
+ * @abstract
+ * @private
+ * @param {string} id The basicModel ID for the saved record
+ * @returns {Deferred}
+ */
+ _rejectSave: function (id) {
+ return Promise.resolve();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * This is the main job of the FMM: deciding what to do when a controlled
+ * field changes. Most of the time, it notifies the model that a change
+ * just occurred, then confirm the change.
+ *
+ * @param {OdooEvent} event
+ */
+ _onFieldChanged: function (event) {
+ // in case of field changed in relational record (e.g. in the form view
+ // of a one2many subrecord), the field_changed event must be stopped as
+ // soon as is it handled by a field_manager (i.e. the one of the
+ // subrecord's form view), otherwise it bubbles up to the main form view
+ // but its model doesn't have any data related to the given dataPointID
+ event.stopPropagation();
+ return this._applyChanges(event.data.dataPointID, event.data.changes, event)
+ .then(event.data.onSuccess || function () {})
+ .guardedCatch(event.data.onFailure || function () {});
+ },
+ /**
+ * Some widgets need to trigger a reload of their data. For example, a
+ * one2many with a pager needs to be able to fetch the next page. To do
+ * that, it can trigger a load event. This will then ask the model to
+ * actually reload the data, then call the on_success callback.
+ *
+ * @param {OdooEvent} event
+ * @param {number} [event.data.limit]
+ * @param {number} [event.data.offset]
+ * @param {function} [event.data.on_success] callback
+ */
+ _onLoad: function (event) {
+ var self = this;
+ event.stopPropagation(); // prevent other field managers from handling this request
+ var data = event.data;
+ if (!data.on_success) { return; }
+ var params = {};
+ if ('limit' in data) {
+ params.limit = data.limit;
+ }
+ if ('offset' in data) {
+ params.offset = data.offset;
+ }
+ this.mutex.exec(function () {
+ return self.model.reload(data.id, params).then(function (db_id) {
+ data.on_success(self.model.get(db_id));
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {function} ev.data.action the function to execute in the mutex
+ */
+ _onMutexify: function (ev) {
+ ev.stopPropagation(); // prevent other field managers from handling this request
+ this.mutex.exec(ev.data.action);
+ },
+};
+
+return FieldManagerMixin;
+});
diff --git a/addons/web/static/src/js/views/file_upload_mixin.js b/addons/web/static/src/js/views/file_upload_mixin.js
new file mode 100644
index 00000000..84dddcd9
--- /dev/null
+++ b/addons/web/static/src/js/views/file_upload_mixin.js
@@ -0,0 +1,234 @@
+odoo.define('web.fileUploadMixin', function (require) {
+'use strict';
+
+/**
+ * Mixin to be used in view Controllers to manage uploads and generate progress bars.
+ * supported views: kanban, list
+ */
+
+const { csrf_token, _t } = require('web.core');
+const ProgressBar = require('web.ProgressBar');
+const ProgressCard = require('web.ProgressCard');
+
+const ProgressBarMixin = {
+
+ custom_events: {
+ progress_bar_abort: '_onProgressBarAbort',
+ },
+
+ init() {
+ /**
+ * Contains the uploads currently happening, used to attach progress bars.
+ * e.g: {'fileUploadId45': {progressBar, progressCard, ...params}}
+ */
+ this._fileUploads = {};
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * used to use a mocked version of Xhr in the tests.
+ *
+ * @private
+ * @returns {XMLHttpRequest}
+ */
+ _createXhr() {
+ return new window.XMLHttpRequest();
+ },
+ /**
+ * @private
+ */
+ _getFileUploadRenderOptions() {
+ return {
+ predicate: () => true,
+ targetCallback: undefined,
+ };
+ },
+ /**
+ * @private
+ * @returns {string} upload route
+ */
+ _getFileUploadRoute() {
+ return '/web/binary/upload_attachment';
+ },
+ /**
+ * @private
+ * @param {Object} params
+ * @param {Object[]} params.files
+ * @param {XMLHttpRequest} params.xhr
+ */
+ _makeFileUpload(params) {
+ const { files, xhr } = params;
+ const fileUploadId = _.uniqueId('fileUploadId');
+ const formData = new FormData();
+ const formDataKeys = this._makeFileUploadFormDataKeys(Object.assign({ fileUploadId }, params));
+
+ formData.append('csrf_token', csrf_token);
+ for (const key in formDataKeys) {
+ if (formDataKeys[key] !== undefined) {
+ formData.append(key, formDataKeys[key]);
+ }
+ }
+ for (const file of files) {
+ formData.append('ufile', file);
+ }
+
+ return {
+ fileUploadId,
+ xhr,
+ title: files.length === 1
+ ? files[0].name
+ : _.str.sprintf(_t("%s Files"), files.length),
+ type: files.length === 1 ? files[0].type : undefined,
+ formData,
+ };
+ },
+ /**
+ * @private
+ * @param {Object} param0
+ * @param {string} param0.fileUploadId
+ * @returns {Object} the list of the form entries of a file upload.
+ */
+ _makeFileUploadFormDataKeys({ fileUploadId }) {
+ return {
+ callback: fileUploadId,
+ };
+ },
+ /**
+ * @private
+ * @param {integer} fileUploadId
+ */
+ async _removeFileUpload(fileUploadId) {
+ const upload = this._fileUploads[fileUploadId];
+ upload.progressCard && upload.progressCard.destroy();
+ upload.progressBar && upload.progressBar.destroy();
+ delete this._fileUploads[fileUploadId];
+ await this.reload();
+ },
+ /**
+ * @private
+ */
+ async _renderFileUploads() {
+ const { predicate, targetCallback } = this._getFileUploadRenderOptions();
+
+ for (const fileUploadId in this._fileUploads) {
+ const upload = this._fileUploads[fileUploadId];
+ if (!predicate(upload)) {
+ continue;
+ }
+
+ if (!upload.progressBar) {
+ if (!upload.recordId || this.viewType !== 'kanban') {
+ upload.progressCard = new ProgressCard(this, {
+ title: upload.title,
+ type: upload.type,
+ viewType: this.viewType,
+ });
+ }
+ upload.progressBar = new ProgressBar(this, {
+ xhr: upload.xhr,
+ title: upload.title,
+ fileUploadId,
+ });
+ }
+
+ let $targetCard;
+ if (upload.progressCard) {
+ await upload.progressCard.prependTo(this.renderer.$el);
+ $targetCard = upload.progressCard.$el;
+ } else if (targetCallback) {
+ $targetCard = targetCallback(upload);
+ }
+ await upload.progressBar.appendTo($targetCard);
+ }
+ },
+ /**
+ * @private
+ * @param {Object[]} files
+ * @param {Object} [params] optional additional data
+ */
+ async _uploadFiles(files, params={}) {
+ if (!files || !files.length) { return; }
+
+ await new Promise(resolve => {
+ const xhr = this._createXhr();
+ xhr.open('POST', this._getFileUploadRoute());
+ const fileUploadData = this._makeFileUpload(Object.assign({ files, xhr }, params));
+ const { fileUploadId, formData } = fileUploadData;
+ this._fileUploads[fileUploadId] = fileUploadData;
+ xhr.upload.addEventListener("progress", ev => {
+ this._updateFileUploadProgress(fileUploadId, ev);
+ });
+ const progressPromise = this._onBeforeUpload();
+ xhr.onload = async () => {
+ await progressPromise;
+ resolve();
+ this._onUploadLoad({ fileUploadId, xhr });
+ };
+ xhr.onerror = async () => {
+ await progressPromise;
+ resolve();
+ this._onUploadError({ fileUploadId, xhr });
+ };
+ xhr.send(formData);
+ });
+ },
+ /**
+ * @private
+ * @param {string} fileUploadId
+ * @param {ProgressEvent} ev
+ */
+ _updateFileUploadProgress(fileUploadId, ev) {
+ const { progressCard, progressBar } = this._fileUploads[fileUploadId];
+ progressCard && progressCard.update(ev.loaded, ev.total);
+ progressBar && progressBar.update(ev.loaded, ev.total);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Hook to customize the behaviour of _uploadFiles() before an upload is made.
+ *
+ * @private
+ */
+ async _onBeforeUpload() {
+ await this._renderFileUploads();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {integer} ev.data.fileUploadId
+ */
+ _onProgressBarAbort(ev) {
+ this._removeFileUpload(ev.data.fileUploadId);
+ },
+ /**
+ * Hook to customize the behaviour of the xhr.onload of an upload.
+ *
+ * @private
+ * @param {string} param0.fileUploadId
+ */
+ _onUploadLoad({ fileUploadId }) {
+ this._removeFileUpload(fileUploadId);
+ },
+ /**
+ * Hook to customize the behaviour of the xhr.onerror of an upload.
+ *
+ * @private
+ * @param {string} param1.fileUploadId
+ * @param {XMLHttpRequest} param0.xhr
+ */
+ _onUploadError({ fileUploadId, xhr }) {
+ this.do_notify(xhr.status, _.str.sprintf(_t('message: %s'), xhr.reponseText), true);
+ this._removeFileUpload(fileUploadId);
+ },
+
+};
+
+return ProgressBarMixin;
+
+});
diff --git a/addons/web/static/src/js/views/file_upload_progress_bar.js b/addons/web/static/src/js/views/file_upload_progress_bar.js
new file mode 100644
index 00000000..6c938e2d
--- /dev/null
+++ b/addons/web/static/src/js/views/file_upload_progress_bar.js
@@ -0,0 +1,76 @@
+odoo.define('web.ProgressBar', function (require) {
+'use strict';
+
+const { _t } = require('web.core');
+const Dialog = require('web.Dialog');
+const Widget = require('web.Widget');
+
+const ProgressBar = Widget.extend({
+ template: 'web.FileUploadProgressBar',
+
+ events: {
+ 'click .o_upload_cross': '_onClickCross',
+ },
+
+ /**
+ * @override
+ * @param {Object} param1
+ * @param {String} param1.title
+ * @param {String} param1.fileUploadId
+ * @param {XMLHttpRequest} param2.xhr
+ */
+ init(parent, { title, fileUploadId, xhr }) {
+ this._super(...arguments);
+ this.title = title;
+ this.fileUploadId = fileUploadId;
+ this.xhr = xhr;
+ },
+
+ /**
+ * @override
+ * @return {Promise}
+ */
+ start() {
+ this.xhr.onabort = () => this.do_notify(false, _t("Upload cancelled"));
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {integer} loaded
+ * @param {integer} total
+ */
+ update(loaded, total) {
+ if (!this.$el) {
+ return;
+ }
+ const percent = Math.round((loaded / total) * 100);
+ this.$('.o_file_upload_progress_bar_value').css("width", percent + "%");
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickCross(ev) {
+ ev.stopPropagation();
+ const promptText = _.str.sprintf(_t("Do you really want to cancel the upload of %s?"), _.escape(this.title));
+ Dialog.confirm(this, promptText, {
+ confirm_callback: () => {
+ this.xhr.abort();
+ this.trigger_up('progress_bar_abort', { fileUploadId: this.fileUploadId });
+ }
+ });
+ },
+});
+
+return ProgressBar;
+
+});
diff --git a/addons/web/static/src/js/views/file_upload_progress_card.js b/addons/web/static/src/js/views/file_upload_progress_card.js
new file mode 100644
index 00000000..1f1db52e
--- /dev/null
+++ b/addons/web/static/src/js/views/file_upload_progress_card.js
@@ -0,0 +1,52 @@
+odoo.define('web.ProgressCard', function (require) {
+'use strict';
+
+const { _t } = require('web.core');
+const Widget = require('web.Widget');
+
+const ProgressCard = Widget.extend({
+ template: 'web.ProgressCard',
+
+ /**
+ * @override
+ * @param {Object} param1
+ * @param {String} param1.title
+ * @param {String} param1.type file mimetype
+ * @param {String} param1.viewType
+ */
+ init(parent, { title, type, viewType }) {
+ this._super(...arguments);
+ this.title = title;
+ this.type = type;
+ this.viewType = viewType;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {integer} loaded
+ * @param {integer} total
+ */
+ update(loaded, total) {
+ if (!this.$el) {
+ return;
+ }
+ const percent = Math.round((loaded / total) * 100);
+ const $textDivLeft = this.$('.o_file_upload_progress_text_left');
+ const $textDivRight = this.$('.o_file_upload_progress_text_right');
+ if (percent === 100) {
+ $textDivLeft.text(_t('Processing...'));
+ } else {
+ const mbLoaded = Math.round(loaded/1000000);
+ const mbTotal = Math.round(total/1000000);
+ $textDivLeft.text(_.str.sprintf(_t("Uploading... (%s%%)"), percent));
+ $textDivRight.text(_.str.sprintf(_t("(%s/%sMb)"), mbLoaded, mbTotal));
+ }
+ },
+});
+
+return ProgressCard;
+
+});
diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js
new file mode 100644
index 00000000..323f7a75
--- /dev/null
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -0,0 +1,691 @@
+odoo.define('web.FormController', function (require) {
+"use strict";
+
+var BasicController = require('web.BasicController');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var dialogs = require('web.view_dialogs');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var FormController = BasicController.extend({
+ custom_events: _.extend({}, BasicController.prototype.custom_events, {
+ button_clicked: '_onButtonClicked',
+ edited_list: '_onEditedList',
+ open_one2many_record: '_onOpenOne2ManyRecord',
+ open_record: '_onOpenRecord',
+ toggle_column_order: '_onToggleColumnOrder',
+ focus_control_button: '_onFocusControlButton',
+ form_dialog_discarded: '_onFormDialogDiscarded',
+ }),
+ /**
+ * @override
+ *
+ * @param {boolean} params.hasActionMenus
+ * @param {Object} params.toolbarActions
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+
+ this.actionButtons = params.actionButtons;
+ this.disableAutofocus = params.disableAutofocus;
+ this.footerToButtons = params.footerToButtons;
+ this.defaultButtons = params.defaultButtons;
+ this.hasActionMenus = params.hasActionMenus;
+ this.toolbarActions = params.toolbarActions || {};
+ },
+ /**
+ * Called each time the form view is attached into the DOM
+ *
+ * @todo convert to new style
+ */
+ on_attach_callback: function () {
+ this._super.apply(this, arguments);
+ this.autofocus();
+ },
+ /**
+ * This hook is called when a form view is restored (by clicking on the
+ * breadcrumbs). In general, we force mode back to readonly, because
+ * whenever we leave a form view by stacking another action on the top of
+ * it, it is saved, and should no longer be in edit mode. However, there is
+ * a special case for new records for which we still want to be in 'edit'
+ * as no record has been created (changes have been discarded before
+ * leaving).
+ *
+ * @override
+ */
+ willRestore: function () {
+ this.mode = this.model.isNew(this.handle) ? 'edit' : 'readonly';
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Calls autofocus on the renderer
+ */
+ autofocus: function () {
+ if (!this.disableAutofocus) {
+ var isControlActivted = this.renderer.autofocus();
+ if (!isControlActivted) {
+ // this can happen in read mode if there are no buttons with
+ // btn-primary class
+ if (this.$buttons && this.mode === 'readonly') {
+ return this.$buttons.find('.o_form_button_edit').focus();
+ }
+ }
+ }
+ },
+ /**
+ * This method switches the form view in edit mode, with a new record.
+ *
+ * @todo make record creation a basic controller feature
+ * @param {string} [parentID] if given, the parentID will be used as parent
+ * for the new record.
+ * @param {Object} [additionalContext]
+ * @returns {Promise}
+ */
+ createRecord: async function (parentID, additionalContext) {
+ const record = this.model.get(this.handle, { raw: true });
+ const handle = await this.model.load({
+ context: record.getContext({ additionalContext: additionalContext}),
+ fields: record.fields,
+ fieldsInfo: record.fieldsInfo,
+ modelName: this.modelName,
+ parentID: parentID,
+ res_ids: record.res_ids,
+ type: 'record',
+ viewType: 'form',
+ });
+ this.handle = handle;
+ this._updateControlPanel();
+ return this._setMode('edit');
+ },
+ /**
+ * Returns the current res_id, wrapped in a list. This is only used by the
+ * action menus (and the debugmanager)
+ *
+ * @override
+ *
+ * @returns {number[]} either [current res_id] or []
+ */
+ getSelectedIds: function () {
+ var env = this.model.get(this.handle, {env: true});
+ return env.currentId ? [env.currentId] : [];
+ },
+ /**
+ * @override method from AbstractController
+ * @returns {string}
+ */
+ getTitle: function () {
+ return this.model.getName(this.handle);
+ },
+ /**
+ * Add the current ID to the state pushed in the url.
+ *
+ * @override
+ */
+ getState: function () {
+ const state = this._super.apply(this, arguments);
+ const env = this.model.get(this.handle, {env: true});
+ state.id = env.currentId;
+ return state;
+ },
+ /**
+ * Render buttons for the control panel. The form view can be rendered in
+ * a dialog, and in that case, if we have buttons defined in the footer, we
+ * have to use them instead of the standard buttons.
+ *
+ * @override method from AbstractController
+ * @param {jQuery} [$node]
+ */
+ renderButtons: function ($node) {
+ var $footer = this.footerToButtons ? this.renderer.$el && this.renderer.$('footer') : null;
+ var mustRenderFooterButtons = $footer && $footer.length;
+ if ((this.defaultButtons && !this.$buttons) || mustRenderFooterButtons) {
+ this.$buttons = $('<div/>');
+ if (mustRenderFooterButtons) {
+ this.$buttons.append($footer);
+ } else {
+ this.$buttons.append(qweb.render("FormView.buttons", {widget: this}));
+ this.$buttons.on('click', '.o_form_button_edit', this._onEdit.bind(this));
+ this.$buttons.on('click', '.o_form_button_create', this._onCreate.bind(this));
+ this.$buttons.on('click', '.o_form_button_save', this._onSave.bind(this));
+ this.$buttons.on('click', '.o_form_button_cancel', this._onDiscard.bind(this));
+ this._assignSaveCancelKeyboardBehavior(this.$buttons.find('.o_form_buttons_edit'));
+ this.$buttons.find('.o_form_buttons_edit').tooltip({
+ delay: {show: 200, hide:0},
+ title: function(){
+ return qweb.render('SaveCancelButton.tooltip');
+ },
+ trigger: 'manual',
+ });
+ }
+ }
+ if (this.$buttons && $node) {
+ this.$buttons.appendTo($node);
+ }
+ },
+ /**
+ * The form view has to prevent a click on the pager if the form is dirty
+ *
+ * @override method from BasicController
+ * @param {jQueryElement} $node
+ * @param {Object} options
+ * @returns {Promise}
+ */
+ _getPagingInfo: function () {
+ // Only display the pager if we are not on a new record.
+ if (this.model.isNew(this.handle)) {
+ return null;
+ }
+ return Object.assign(this._super(...arguments), {
+ validate: this.canBeDiscarded.bind(this),
+ });
+ },
+ /**
+ * @override
+ * @private
+ **/
+ _getActionMenuItems: function (state) {
+ if (!this.hasActionMenus || this.mode === 'edit') {
+ return null;
+ }
+ const props = this._super(...arguments);
+ const activeField = this.model.getActiveField(state);
+ const otherActionItems = [];
+ if (this.archiveEnabled && activeField in state.data) {
+ if (state.data[activeField]) {
+ otherActionItems.push({
+ description: _t("Archive"),
+ callback: () => {
+ Dialog.confirm(this, _t("Are you sure that you want to archive this record?"), {
+ confirm_callback: () => this._toggleArchiveState(true),
+ });
+ },
+ });
+ } else {
+ otherActionItems.push({
+ description: _t("Unarchive"),
+ callback: () => this._toggleArchiveState(false),
+ });
+ }
+ }
+ if (this.activeActions.create && this.activeActions.duplicate) {
+ otherActionItems.push({
+ description: _t("Duplicate"),
+ callback: () => this._onDuplicateRecord(this),
+ });
+ }
+ if (this.activeActions.delete) {
+ otherActionItems.push({
+ description: _t("Delete"),
+ callback: () => this._onDeleteRecord(this),
+ });
+ }
+ return Object.assign(props, {
+ items: Object.assign(this.toolbarActions, { other: otherActionItems }),
+ });
+ },
+ /**
+ * Show a warning message if the user modified a translated field. For each
+ * field, the notification provides a link to edit the field's translations.
+ *
+ * @override
+ */
+ saveRecord: async function () {
+ const changedFields = await this._super(...arguments);
+ // the title could have been changed
+ this._updateControlPanel();
+
+ if (_t.database.multi_lang && changedFields.length) {
+ // need to make sure changed fields that should be translated
+ // are displayed with an alert
+ var fields = this.renderer.state.fields;
+ var data = this.renderer.state.data;
+ var alertFields = {};
+ for (var k = 0; k < changedFields.length; k++) {
+ var field = fields[changedFields[k]];
+ var fieldData = data[changedFields[k]];
+ if (field.translate && fieldData && fieldData !== '<p><br></p>') {
+ alertFields[changedFields[k]] = field;
+ }
+ }
+ if (!_.isEmpty(alertFields)) {
+ this.renderer.updateAlertFields(alertFields);
+ }
+ }
+ return changedFields;
+ },
+ /**
+ * Overrides to force the viewType to 'form', so that we ensure that the
+ * correct fields are reloaded (this is only useful for one2many form views).
+ *
+ * @override
+ */
+ update: async function (params, options) {
+ if ('currentId' in params && !params.currentId) {
+ this.mode = 'edit'; // if there is no record, we are in 'edit' mode
+ }
+ params = _.extend({viewType: 'form', mode: this.mode}, params);
+ await this._super(params, options);
+ this.autofocus();
+ },
+ /**
+ * @override
+ */
+ updateButtons: function () {
+ if (!this.$buttons) {
+ return;
+ }
+ if (this.footerToButtons) {
+ var $footer = this.renderer.$el && this.renderer.$('footer');
+ if ($footer && $footer.length) {
+ this.$buttons.empty().append($footer);
+ }
+ }
+ var edit_mode = (this.mode === 'edit');
+ this.$buttons.find('.o_form_buttons_edit')
+ .toggleClass('o_hidden', !edit_mode);
+ this.$buttons.find('.o_form_buttons_view')
+ .toggleClass('o_hidden', edit_mode);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _applyChanges: async function () {
+ const result = await this._super.apply(this, arguments);
+ core.bus.trigger('DOM_updated');
+ return result;
+ },
+ /**
+ * Assign on the buttons save and discard additionnal behavior to facilitate
+ * the work of the users doing input only using the keyboard
+ *
+ * @param {jQueryElement} $saveCancelButtonContainer The div containing the
+ * save and cancel buttons
+ * @private
+ */
+ _assignSaveCancelKeyboardBehavior: function ($saveCancelButtonContainer) {
+ var self = this;
+ $saveCancelButtonContainer.children().on('keydown', function (e) {
+ switch(e.which) {
+ case $.ui.keyCode.ENTER:
+ e.preventDefault();
+ self.saveRecord();
+ break;
+ case $.ui.keyCode.ESCAPE:
+ e.preventDefault();
+ self._discardChanges();
+ break;
+ case $.ui.keyCode.TAB:
+ if (!e.shiftKey && e.target.classList.contains('btn-primary')) {
+ $saveCancelButtonContainer.tooltip('show');
+ e.preventDefault();
+ }
+ break;
+ }
+ });
+ },
+ /**
+ * When a save operation has been confirmed from the model, this method is
+ * called.
+ *
+ * @private
+ * @override method from field manager mixin
+ * @param {string} id - id of the previously changed record
+ * @returns {Promise}
+ */
+ _confirmSave: function (id) {
+ if (id === this.handle) {
+ if (this.mode === 'readonly') {
+ return this.reload();
+ } else {
+ return this._setMode('readonly');
+ }
+ } else {
+ // A subrecord has changed, so update the corresponding relational field
+ // i.e. the one whose value is a record with the given id or a list
+ // having a record with the given id in its data
+ var record = this.model.get(this.handle);
+
+ // Callback function which returns true
+ // if a value recursively contains a record with the given id.
+ // This will be used to determine the list of fields to reload.
+ var containsChangedRecord = function (value) {
+ return _.isObject(value) &&
+ (value.id === id || _.find(value.data, containsChangedRecord));
+ };
+
+ var changedFields = _.findKey(record.data, containsChangedRecord);
+ return this.renderer.confirmChange(record, record.id, [changedFields]);
+ }
+ },
+ /**
+ * Override to disable buttons in the renderer.
+ *
+ * @override
+ * @private
+ */
+ _disableButtons: function () {
+ this._super.apply(this, arguments);
+ this.renderer.disableButtons();
+ },
+ /**
+ * Override to enable buttons in the renderer.
+ *
+ * @override
+ * @private
+ */
+ _enableButtons: function () {
+ this._super.apply(this, arguments);
+ this.renderer.enableButtons();
+ },
+ /**
+ * Hook method, called when record(s) has been deleted.
+ *
+ * @override
+ */
+ _onDeletedRecords: function () {
+ var state = this.model.get(this.handle, {raw: true});
+ if (!state.res_ids.length) {
+ this.trigger_up('history_back');
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+ /**
+ * Overrides to reload the form when saving failed in readonly (e.g. after
+ * a change on a widget like priority or statusbar).
+ *
+ * @override
+ * @private
+ */
+ _rejectSave: function () {
+ if (this.mode === 'readonly') {
+ return this.reload();
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Calls unfreezeOrder when changing the mode.
+ * Also, when there is a change of mode, the tracking of last activated
+ * field is reset, so that the following field activation process starts
+ * with the 1st field.
+ *
+ * @override
+ */
+ _setMode: function (mode, recordID) {
+ if ((recordID || this.handle) === this.handle) {
+ this.model.unfreezeOrder(this.handle);
+ }
+ if (this.mode !== mode) {
+ this.renderer.resetLastActivatedField();
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ _shouldBounceOnClick(element) {
+ return this.mode === 'readonly' && !!element.closest('.oe_title, .o_inner_group');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onButtonClicked: function (ev) {
+ // stop the event's propagation as a form controller might have other
+ // form controllers in its descendants (e.g. in a FormViewDialog)
+ ev.stopPropagation();
+ var self = this;
+ var def;
+
+ this._disableButtons();
+
+ function saveAndExecuteAction () {
+ return self.saveRecord(self.handle, {
+ stayInEdit: true,
+ }).then(function () {
+ // we need to reget the record to make sure we have changes made
+ // by the basic model, such as the new res_id, if the record is
+ // new.
+ var record = self.model.get(ev.data.record.id);
+ return self._callButtonAction(attrs, record);
+ });
+ }
+ var attrs = ev.data.attrs;
+ if (attrs.confirm) {
+ def = new Promise(function (resolve, reject) {
+ Dialog.confirm(self, attrs.confirm, {
+ confirm_callback: saveAndExecuteAction,
+ }).on("closed", null, resolve);
+ });
+ } else if (attrs.special === 'cancel') {
+ def = this._callButtonAction(attrs, ev.data.record);
+ } else if (!attrs.special || attrs.special === 'save') {
+ // save the record but don't switch to readonly mode
+ def = saveAndExecuteAction();
+ } else {
+ console.warn('Unhandled button event', ev);
+ return;
+ }
+
+ // Kind of hack for FormViewDialog: button on footer should trigger the dialog closing
+ // if the `close` attribute is set
+ def.then(function () {
+ self._enableButtons();
+ if (attrs.close) {
+ self.trigger_up('close_dialog');
+ }
+ }).guardedCatch(this._enableButtons.bind(this));
+ },
+ /**
+ * Called when the user wants to create a new record -> @see createRecord
+ *
+ * @private
+ */
+ _onCreate: function () {
+ this.createRecord();
+ },
+ /**
+ * Deletes the current record
+ *
+ * @private
+ */
+ _onDeleteRecord: function () {
+ this._deleteRecords([this.handle]);
+ },
+ /**
+ * Called when the user wants to discard the changes made to the current
+ * record -> @see discardChanges
+ *
+ * @private
+ */
+ _onDiscard: function () {
+ this._disableButtons();
+ this._discardChanges()
+ .then(this._enableButtons.bind(this))
+ .guardedCatch(this._enableButtons.bind(this));
+ },
+ /**
+ * Called when the user clicks on 'Duplicate Record' in the action menus
+ *
+ * @private
+ */
+ _onDuplicateRecord: async function () {
+ const handle = await this.model.duplicateRecord(this.handle);
+ this.handle = handle;
+ this._updateControlPanel();
+ this._setMode('edit');
+ },
+ /**
+ * Called when the user wants to edit the current record -> @see _setMode
+ *
+ * @private
+ */
+ _onEdit: function () {
+ this._disableButtons();
+ // wait for potential pending changes to be saved (done with widgets
+ // allowing to edit in readonly)
+ this.mutex.getUnlockedDef()
+ .then(this._setMode.bind(this, 'edit'))
+ .then(this._enableButtons.bind(this))
+ .guardedCatch(this._enableButtons.bind(this));
+ },
+ /**
+ * This method is called when someone tries to freeze the order, most likely
+ * in a x2many list view
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {integer} ev.id of the list to freeze while editing a line
+ */
+ _onEditedList: function (ev) {
+ ev.stopPropagation();
+ if (ev.data.id) {
+ this.model.save(ev.data.id, {savePoint: true});
+ }
+ this.model.freezeOrder(ev.data.id);
+ },
+ /**
+ * Set the focus on the first primary button of the controller (likely Edit)
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onFocusControlButton:function(e) {
+ if (this.$buttons) {
+ e.stopPropagation();
+ this.$buttons.find('.btn-primary:visible:first()').focus();
+ }
+ },
+ /**
+ * Reset the focus on the control that openned a Dialog after it was closed
+ *
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onFormDialogDiscarded: function(ev) {
+ ev.stopPropagation();
+ var isFocused = this.renderer.focusLastActivatedWidget();
+ if (ev.data.callback) {
+ ev.data.callback(_.str.toBool(isFocused));
+ }
+ },
+ /**
+ * Opens a one2many record (potentially new) in a dialog. This handler is
+ * o2m specific as in this case, the changes done on the related record
+ * shouldn't be saved in DB when the user clicks on 'Save' in the dialog,
+ * but later on when he clicks on 'Save' in the main form view. For this to
+ * work correctly, the main model and the local id of the opened record must
+ * be given to the dialog, which will complete the viewInfo of the record
+ * with the one of the form view.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onOpenOne2ManyRecord: async function (ev) {
+ ev.stopPropagation();
+ var data = ev.data;
+ var record;
+ if (data.id) {
+ record = this.model.get(data.id, {raw: true});
+ }
+
+ // Sync with the mutex to wait for potential onchanges
+ await this.model.mutex.getUnlockedDef();
+
+ new dialogs.FormViewDialog(this, {
+ context: data.context,
+ domain: data.domain,
+ fields_view: data.fields_view,
+ model: this.model,
+ on_saved: data.on_saved,
+ on_remove: data.on_remove,
+ parentID: data.parentID,
+ readonly: data.readonly,
+ deletable: record ? data.deletable : false,
+ recordID: record && record.id,
+ res_id: record && record.res_id,
+ res_model: data.field.relation,
+ shouldSaveLocally: true,
+ title: (record ? _t("Open: ") : _t("Create ")) + (ev.target.string || data.field.string),
+ }).open();
+ },
+ /**
+ * Open an existing record in a form view dialog
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onOpenRecord: function (ev) {
+ ev.stopPropagation();
+ var self = this;
+ var record = this.model.get(ev.data.id, {raw: true});
+ new dialogs.FormViewDialog(self, {
+ context: ev.data.context,
+ fields_view: ev.data.fields_view,
+ on_saved: ev.data.on_saved,
+ on_remove: ev.data.on_remove,
+ readonly: ev.data.readonly,
+ deletable: ev.data.deletable,
+ res_id: record.res_id,
+ res_model: record.model,
+ title: _t("Open: ") + ev.data.string,
+ }).open();
+ },
+ /**
+ * Called when the user wants to save the current record -> @see saveRecord
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onSave: function (ev) {
+ ev.stopPropagation(); // Prevent x2m lines to be auto-saved
+ this._disableButtons();
+ this.saveRecord().then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this));
+ },
+ /**
+ * This method is called when someone tries to sort a column, most likely
+ * in a x2many list view
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onToggleColumnOrder: function (ev) {
+ ev.stopPropagation();
+ var self = this;
+ this.model.setSort(ev.data.id, ev.data.name).then(function () {
+ var field = ev.data.field;
+ var state = self.model.get(self.handle);
+ self.renderer.confirmChange(state, state.id, [field]);
+ });
+ },
+ /**
+ * Called when clicking on 'Archive' or 'Unarchive' in the action menus.
+ *
+ * @private
+ * @param {boolean} archive
+ */
+ _toggleArchiveState: function (archive) {
+ const resIds = this.model.localIdsToResIds([this.handle]);
+ this._archive(resIds, archive);
+ },
+});
+
+return FormController;
+
+});
diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js
new file mode 100644
index 00000000..e4c1b187
--- /dev/null
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -0,0 +1,1211 @@
+odoo.define('web.FormRenderer', function (require) {
+"use strict";
+
+var BasicRenderer = require('web.BasicRenderer');
+var config = require('web.config');
+var core = require('web.core');
+var dom = require('web.dom');
+var viewUtils = require('web.viewUtils');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+// symbol used as key to set the <field> node id on its widget
+const symbol = Symbol('form');
+
+var FormRenderer = BasicRenderer.extend({
+ className: "o_form_view",
+ events: _.extend({}, BasicRenderer.prototype.events, {
+ 'click .o_notification_box .oe_field_translate': '_onTranslate',
+ 'click .o_notification_box .close': '_onTranslateNotificationClose',
+ 'shown.bs.tab a[data-toggle="tab"]': '_onNotebookTabChanged',
+ }),
+ custom_events: _.extend({}, BasicRenderer.prototype.custom_events, {
+ 'navigation_move':'_onNavigationMove',
+ 'activate_next_widget' : '_onActivateNextWidget',
+ }),
+ // default col attributes for the rendering of groups
+ INNER_GROUP_COL: 2,
+ OUTER_GROUP_COL: 2,
+
+ /**
+ * @override
+ * @param {Object} params.fieldIdsToNames maps <field> node ids to field names
+ * (useful when there are several occurrences of the same field in the arch)
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this.fieldIdsToNames = params.fieldIdsToNames;
+ this.idsForLabels = {};
+ this.lastActivatedFieldIndex = -1;
+ this.alertFields = {};
+ // The form renderer doesn't render invsible fields (invisible="1") by
+ // default, to speed up the rendering. However, we sometimes have to
+ // display them (e.g. in Studio, in "show invisible" mode). This flag
+ // allows to disable this optimization.
+ this.renderInvisible = false;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this._applyFormSizeClass();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Focuses the field having attribute 'default_focus' set, if any, or the
+ * first focusable field otherwise.
+ * In read mode, delegate which button to give the focus to, to the form_renderer
+ *
+ * @returns {int | undefined} the index of the widget activated else
+ * undefined
+ */
+ autofocus: function () {
+ if (this.mode === 'readonly') {
+ var firstPrimaryFormButton = this.$el.find('button.btn-primary:enabled:visible:first()');
+ if (firstPrimaryFormButton.length > 0) {
+ return firstPrimaryFormButton.focus();
+ } else {
+ return;
+ }
+ }
+ var focusWidget = this.defaultFocusField;
+ if (!focusWidget || !focusWidget.isFocusable()) {
+ var widgets = this.allFieldWidgets[this.state.id];
+ for (var i = 0; i < (widgets ? widgets.length : 0); i++) {
+ var widget = widgets[i];
+ if (widget.isFocusable()) {
+ focusWidget = widget;
+ break;
+ }
+ }
+ }
+ if (focusWidget) {
+ return focusWidget.activate({noselect: true, noAutomaticCreate: true});
+ }
+ },
+ /**
+ * Extend the method so that labels also receive the 'o_field_invalid' class
+ * if necessary.
+ *
+ * @override
+ * @see BasicRenderer.canBeSaved
+ * @param {string} recordID
+ * @returns {string[]}
+ */
+ canBeSaved: function () {
+ var fieldNames = this._super.apply(this, arguments);
+
+ var $labels = this.$('label');
+ $labels.removeClass('o_field_invalid');
+
+ const allWidgets = this.allFieldWidgets[this.state.id] || [];
+ const widgets = allWidgets.filter(w => fieldNames.includes(w.name));
+ for (const widget of widgets) {
+ const idForLabel = this.idsForLabels[widget[symbol]];
+ if (idForLabel) {
+ $labels
+ .filter('[for=' + idForLabel + ']')
+ .addClass('o_field_invalid');
+ }
+ }
+ return fieldNames;
+ },
+ /*
+ * Updates translation alert fields for the current state and display updated fields
+ *
+ * @param {Object} alertFields
+ */
+ updateAlertFields: function (alertFields) {
+ this.alertFields[this.state.res_id] = _.extend(this.alertFields[this.state.res_id] || {}, alertFields);
+ this.displayTranslationAlert();
+ },
+ /**
+ * Show a warning message if the user modified a translated field. For each
+ * field, the notification provides a link to edit the field's translations.
+ */
+ displayTranslationAlert: function () {
+ this.$('.o_notification_box').remove();
+ if (this.alertFields[this.state.res_id]) {
+ var $notification = $(qweb.render('notification-box', {type: 'info'}))
+ .append(qweb.render('translation-alert', {
+ fields: this.alertFields[this.state.res_id],
+ lang: _t.database.parameters.name
+ }));
+ if (this.$('.o_form_statusbar').length) {
+ this.$('.o_form_statusbar').after($notification);
+ } else if (this.$('.o_form_sheet_bg').length) {
+ this.$('.o_form_sheet_bg').prepend($notification);
+ } else {
+ this.$el.prepend($notification);
+ }
+ }
+ },
+ /**
+ * @see BasicRenderer.confirmChange
+ *
+ * We need to reapply the idForLabel postprocessing since some widgets may
+ * have recomputed their dom entirely.
+ *
+ * @override
+ */
+ confirmChange: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function (resetWidgets) {
+ _.each(resetWidgets, function (widget) {
+ self._setIDForLabel(widget, self.idsForLabels[widget[symbol]]);
+ });
+ if (self.$('.o_field_invalid').length) {
+ self.canBeSaved(self.state.id);
+ }
+ return resetWidgets;
+ });
+ },
+ /**
+ * Disable statusbar buttons and stat buttons so that they can't be clicked anymore
+ *
+ */
+ disableButtons: function () {
+ this.$('.o_statusbar_buttons button, .oe_button_box button')
+ .attr('disabled', true);
+ },
+ /**
+ * Enable statusbar buttons and stat buttons so they can be clicked again
+ *
+ */
+ enableButtons: function () {
+ this.$('.o_statusbar_buttons button, .oe_button_box button')
+ .removeAttr('disabled');
+ },
+ /**
+ * Put the focus on the last activated widget.
+ * This function is used when closing a dialog to give the focus back to the
+ * form that has opened it and ensures that the focus is in the correct
+ * field.
+ */
+ focusLastActivatedWidget: function () {
+ if (this.lastActivatedFieldIndex !== -1) {
+ return this._activateNextFieldWidget(this.state, this.lastActivatedFieldIndex - 1,
+ { noAutomaticCreate: true });
+ }
+ return false;
+ },
+ /**
+ * returns the active tab pages for each notebook
+ *
+ * @todo currently, this method is unused...
+ *
+ * @see setLocalState
+ * @returns {Object} a map from notebook name to the active tab index
+ */
+ getLocalState: function () {
+ const state = {};
+ for (const notebook of this.el.querySelectorAll(':scope div.o_notebook')) {
+ const name = notebook.dataset.name;
+ const navs = notebook.querySelectorAll(':scope .o_notebook_headers .nav-item > .nav-link');
+ state[name] = Math.max([...navs].findIndex(
+ nav => nav.classList.contains('active')
+ ), 0);
+ }
+ return state;
+ },
+ /**
+ * Reset the tracking of the last activated field. The fast entry with
+ * keyboard navigation needs to track the last activated field in order to
+ * set the focus.
+ *
+ * In particular, when there are changes of mode (e.g. edit -> readonly ->
+ * edit), we do not want to auto-set the focus on the previously last
+ * activated field. To avoid this issue, this method should be called
+ * whenever there is a change of mode.
+ */
+ resetLastActivatedField: function () {
+ this.lastActivatedFieldIndex = -1;
+ },
+ /**
+ * Resets state which stores information like scroll position, curently
+ * active page, ...
+ *
+ * @override
+ */
+ resetLocalState() {
+ for (const notebook of this.el.querySelectorAll(':scope div.o_notebook')) {
+ [...notebook.querySelectorAll(':scope .o_notebook_headers .nav-item .nav-link')]
+ .map(nav => nav.classList.remove('active'));
+ [...notebook.querySelectorAll(':scope .tab-content > .tab-pane')]
+ .map(tab => tab.classList.remove('active'));
+ }
+
+ },
+ /**
+ * Restore active tab pages for each notebook. It relies on the implicit fact
+ * that each nav header corresponds to a tab page.
+ *
+ * @param {Object} state the result from a getLocalState call
+ */
+ setLocalState: function (state) {
+ for (const notebook of this.el.querySelectorAll(':scope div.o_notebook')) {
+ if (notebook.closest(".o_field_widget")) {
+ continue;
+ }
+ const name = notebook.dataset.name;
+ if (name in state) {
+ const navs = notebook.querySelectorAll(':scope .o_notebook_headers .nav-item');
+ const pages = notebook.querySelectorAll(':scope > .tab-content > .tab-pane');
+ // We can't base the amount on the 'navs' length since some overrides
+ // are adding pageless nav items.
+ const validTabsAmount = pages.length;
+ if (!validTabsAmount) {
+ continue; // No page defined on the notebook.
+ }
+ let activeIndex = state[name];
+ if (navs[activeIndex].classList.contains('o_invisible_modifier')) {
+ activeIndex = [...navs].findIndex(
+ nav => !nav.classList.contains('o_invisible_modifier')
+ );
+ }
+ if (activeIndex <= 0) {
+ continue; // No visible tab OR first tab = active tab (no change to make).
+ }
+ for (let i = 0; i < validTabsAmount; i++) {
+ navs[i].querySelector('.nav-link').classList.toggle('active', activeIndex === i);
+ pages[i].classList.toggle('active', activeIndex === i);
+ }
+ core.bus.trigger('DOM_updated');
+ }
+ }
+ },
+ /**
+ * @override method from AbstractRenderer
+ * @param {Object} state a valid state given by the model
+ * @param {Object} params
+ * @param {string} [params.mode] new mode, either 'edit' or 'readonly'
+ * @param {string[]} [params.fieldNames] if given, the renderer will only
+ * update the fields in this list
+ * @returns {Promise}
+ */
+ updateState: function (state, params) {
+ this._setState(state);
+ this.mode = (params && 'mode' in params) ? params.mode : this.mode;
+
+ // if fieldNames are given, we update the corresponding field widget.
+ // I think this is wrong, and the caller could directly call the
+ // confirmChange method
+ if (params.fieldNames) {
+ // only update the given fields
+ return this.confirmChange(this.state, this.state.id, params.fieldNames);
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Activates the first visible tab from a given list of tab objects. The
+ * first tab having an "autofocus" attribute set will be focused in
+ * priority.
+ *
+ * @private
+ * @param {Object[]} tabs
+ */
+ _activateFirstVisibleTab(tabs) {
+ const visibleTabs = tabs.filter(
+ (tab) => !tab.$header.hasClass("o_invisible_modifier")
+ );
+ const autofocusTab = visibleTabs.findIndex(
+ (tab) => tab.node.attrs.autofocus === "autofocus"
+ );
+ const tabToFocus = visibleTabs[Math.max(0, autofocusTab)];
+ if (tabToFocus) {
+ tabToFocus.$header.find('.nav-link').addClass('active');
+ tabToFocus.$page.addClass('active');
+ }
+ },
+ /**
+ * @override
+ */
+ _activateNextFieldWidget: function (record, currentIndex) {
+ //if we are the last widget, we should give the focus to the first Primary Button in the form
+ //else do the default behavior
+ if ( (currentIndex + 1) >= (this.allFieldWidgets[record.id] || []).length) {
+ this.trigger_up('focus_control_button');
+ this.lastActivatedFieldIndex = -1;
+ } else {
+ var activatedIndex = this._super.apply(this, arguments);
+ if (activatedIndex === -1 ) { // no widget have been activated, we should go to the edit/save buttons
+ this.trigger_up('focus_control_button');
+ this.lastActivatedFieldIndex = -1;
+ }
+ else {
+ this.lastActivatedFieldIndex = activatedIndex;
+ }
+ }
+ return this.lastActivatedFieldIndex;
+ },
+ /**
+ * Add a tooltip on a button
+ *
+ * @private
+ * @param {Object} node
+ * @param {jQuery} $button
+ */
+ _addButtonTooltip: function (node, $button) {
+ var self = this;
+ $button.tooltip({
+ title: function () {
+ return qweb.render('WidgetButton.tooltip', {
+ debug: config.isDebug(),
+ state: self.state,
+ node: node,
+ });
+ },
+ });
+ },
+ /**
+ * @private
+ * @param {jQueryElement} $el
+ * @param {Object} node
+ */
+ _addOnClickAction: function ($el, node) {
+ if (node.attrs.special || node.attrs.confirm || node.attrs.type || $el.hasClass('oe_stat_button')) {
+ var self = this;
+ $el.on("click", function () {
+ self.trigger_up('button_clicked', {
+ attrs: node.attrs,
+ record: self.state,
+ });
+ });
+ }
+ },
+ _applyFormSizeClass: function () {
+ const formEl = this.$el[0];
+ if (config.device.size_class <= config.device.SIZES.XS) {
+ formEl.classList.add('o_xxs_form_view');
+ } else {
+ formEl.classList.remove('o_xxs_form_view');
+ }
+ if (config.device.size_class === config.device.SIZES.XXL) {
+ formEl.classList.add('o_xxl_form_view');
+ } else {
+ formEl.classList.remove('o_xxl_form_view');
+ }
+ },
+ /**
+ * @private
+ * @param {string} uid a <field> node id
+ * @returns {string}
+ */
+ _getIDForLabel: function (uid) {
+ if (!this.idsForLabels[uid]) {
+ this.idsForLabels[uid] = _.uniqueId('o_field_input_');
+ }
+ return this.idsForLabels[uid];
+ },
+ /**
+ * @override
+ * @private
+ */
+ _getRecord: function (recordId) {
+ return this.state.id === recordId ? this.state : null;
+ },
+ /**
+ * @override
+ * @private
+ */
+ _postProcessField: function (widget, node) {
+ this._super.apply(this, arguments);
+ // set the node id on the widget, as it might be necessary later (tooltips, confirmChange...)
+ widget[symbol] = node.attrs.id;
+ this._setIDForLabel(widget, this._getIDForLabel(node.attrs.id));
+ if (JSON.parse(node.attrs.default_focus || "0")) {
+ this.defaultFocusField = widget;
+ }
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderButtonBox: function (node) {
+ var self = this;
+ var $result = $('<' + node.tag + '>', {class: 'o_not_full'});
+
+ // The rendering of buttons may be async (see renderFieldWidget), so we
+ // must wait for the buttons to be ready (and their modifiers to be
+ // applied) before manipulating them, as we check if they are visible or
+ // not. To do so, we extract from this.defs the promises corresponding
+ // to the buttonbox buttons, and wait for them to be resolved.
+ var nextDefIndex = this.defs.length;
+ var buttons = _.map(node.children, function (child) {
+ if (child.tag === 'button') {
+ return self._renderStatButton(child);
+ } else {
+ return self._renderNode(child);
+ }
+ });
+
+ // At this point, each button is an empty div that will be replaced by
+ // the real $el of the button when it is ready (with replaceWith).
+ // However, this only works if the empty div is appended somewhere, so
+ // we here append them into a wrapper, and unwrap them once they have
+ // been replaced.
+ var $tempWrapper = $('<div>');
+ _.each(buttons, function ($button) {
+ $button.appendTo($tempWrapper);
+ });
+ var defs = this.defs.slice(nextDefIndex);
+ Promise.all(defs).then(function () {
+ buttons = $tempWrapper.children();
+ var buttons_partition = _.partition(buttons, function (button) {
+ return $(button).is('.o_invisible_modifier');
+ });
+ var invisible_buttons = buttons_partition[0];
+ var visible_buttons = buttons_partition[1];
+
+ // Get the unfolded buttons according to window size
+ var nb_buttons = self._renderButtonBoxNbButtons();
+ var unfolded_buttons = visible_buttons.slice(0, nb_buttons).concat(invisible_buttons);
+
+ // Get the folded buttons
+ var folded_buttons = visible_buttons.slice(nb_buttons);
+ if (folded_buttons.length === 1) {
+ unfolded_buttons = buttons;
+ folded_buttons = [];
+ }
+
+ // Toggle class to tell if the button box is full (CSS requirement)
+ var full = (visible_buttons.length > nb_buttons);
+ $result.toggleClass('o_full', full).toggleClass('o_not_full', !full);
+
+ // Add the unfolded buttons
+ _.each(unfolded_buttons, function (button) {
+ $(button).appendTo($result);
+ });
+
+ // Add the dropdown with folded buttons if any
+ if (folded_buttons.length) {
+ $result.append(dom.renderButton({
+ attrs: {
+ 'class': 'oe_stat_button o_button_more dropdown-toggle',
+ 'data-toggle': 'dropdown',
+ },
+ text: _t("More"),
+ }));
+
+ var $dropdown = $("<div>", {class: "dropdown-menu o_dropdown_more", role: "menu"});
+ _.each(folded_buttons, function (button) {
+ $(button).addClass('dropdown-item').appendTo($dropdown);
+ });
+ $dropdown.appendTo($result);
+ }
+ });
+
+ this._handleAttributes($result, node);
+ this._registerModifiers(node, this.state, $result);
+ return $result;
+ },
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _renderButtonBoxNbButtons: function () {
+ return [2, 2, 2, 4][config.device.size_class] || 7;
+ },
+ /**
+ * Do not render a field widget if it is always invisible.
+ *
+ * @override
+ */
+ _renderFieldWidget(node) {
+ if (!this.renderInvisible && node.attrs.modifiers.invisible === true) {
+ return $();
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderGenericTag: function (node) {
+ var $result = $('<' + node.tag + '>', _.omit(node.attrs, 'modifiers'));
+ this._handleAttributes($result, node);
+ this._registerModifiers(node, this.state, $result);
+ $result.append(_.map(node.children, this._renderNode.bind(this)));
+ return $result;
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderHeaderButton: function (node) {
+ var $button = viewUtils.renderButtonFromNode(node);
+
+ // Current API of odoo for rendering buttons is "if classes are given
+ // use those on top of the 'btn' and 'btn-{size}' classes, otherwise act
+ // as if 'btn-secondary' class was given". The problem is that, for
+ // header buttons only, we allowed users to only indicate their custom
+ // classes without having to explicitely ask for the 'btn-secondary'
+ // class to be added. We force it so here when no bootstrap btn type
+ // class is found.
+ if ($button.not('.btn-primary, .btn-secondary, .btn-link, .btn-success, .btn-info, .btn-warning, .btn-danger').length) {
+ $button.addClass('btn-secondary');
+ }
+
+ this._addOnClickAction($button, node);
+ this._handleAttributes($button, node);
+ this._registerModifiers(node, this.state, $button);
+
+ // Display tooltip
+ if (config.isDebug() || node.attrs.help) {
+ this._addButtonTooltip(node, $button);
+ }
+ return $button;
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderHeaderButtons: function (node) {
+ var self = this;
+ var buttons = [];
+ _.each(node.children, function (child) {
+ if (child.tag === 'button') {
+ buttons.push(self._renderHeaderButton(child));
+ }
+ if (child.tag === 'widget') {
+ buttons.push(self._renderTagWidget(child));
+ }
+ });
+ return this._renderStatusbarButtons(buttons);
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderInnerGroup: function (node) {
+ var self = this;
+ var $result = $('<table/>', {class: 'o_group o_inner_group'});
+ var $tbody = $('<tbody />').appendTo($result);
+ this._handleAttributes($result, node);
+ this._registerModifiers(node, this.state, $result);
+
+ var col = parseInt(node.attrs.col, 10) || this.INNER_GROUP_COL;
+
+ if (node.attrs.string) {
+ var $sep = $('<tr><td colspan="' + col + '" style="width: 100%;"><div class="o_horizontal_separator">' + node.attrs.string + '</div></td></tr>');
+ $result.append($sep);
+ }
+
+ var rows = [];
+ var $currentRow = $('<tr/>');
+ var currentColspan = 0;
+ node.children.forEach(function (child) {
+ if (child.tag === 'newline') {
+ rows.push($currentRow);
+ $currentRow = $('<tr/>');
+ currentColspan = 0;
+ return;
+ }
+
+ var colspan = parseInt(child.attrs.colspan, 10);
+ var isLabeledField = (child.tag === 'field' && child.attrs.nolabel !== '1');
+ if (!colspan) {
+ if (isLabeledField) {
+ colspan = 2;
+ } else {
+ colspan = 1;
+ }
+ }
+ var finalColspan = colspan - (isLabeledField ? 1 : 0);
+ currentColspan += colspan;
+
+ if (currentColspan > col) {
+ rows.push($currentRow);
+ $currentRow = $('<tr/>');
+ currentColspan = colspan;
+ }
+
+ var $tds;
+ if (child.tag === 'field') {
+ $tds = self._renderInnerGroupField(child);
+ } else if (child.tag === 'label') {
+ $tds = self._renderInnerGroupLabel(child);
+ } else {
+ var $td = $('<td/>');
+ var $child = self._renderNode(child);
+ if ($child.hasClass('o_td_label')) { // transfer classname to outer td for css reasons
+ $td.addClass('o_td_label');
+ $child.removeClass('o_td_label');
+ }
+ $tds = $td.append($child);
+ }
+ if (finalColspan > 1) {
+ $tds.last().attr('colspan', finalColspan);
+ }
+ $currentRow.append($tds);
+ });
+ rows.push($currentRow);
+
+ _.each(rows, function ($tr) {
+ var nonLabelColSize = 100 / (col - $tr.children('.o_td_label').length);
+ _.each($tr.children(':not(.o_td_label)'), function (el) {
+ var $el = $(el);
+ $el.css('width', ((parseInt($el.attr('colspan'), 10) || 1) * nonLabelColSize) + '%');
+ });
+ $tbody.append($tr);
+ });
+
+ return $result;
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderInnerGroupField: function (node) {
+ var $el = this._renderFieldWidget(node, this.state);
+ var $tds = $('<td/>').append($el);
+
+ if (node.attrs.nolabel !== '1') {
+ var $labelTd = this._renderInnerGroupLabel(node);
+ $tds = $labelTd.add($tds);
+
+ // apply the oe_(edit|read)_only className on the label as well
+ if (/\boe_edit_only\b/.test(node.attrs.class)) {
+ $tds.addClass('oe_edit_only');
+ }
+ if (/\boe_read_only\b/.test(node.attrs.class)) {
+ $tds.addClass('oe_read_only');
+ }
+ }
+
+ return $tds;
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderInnerGroupLabel: function (node) {
+ return $('<td/>', {class: 'o_td_label'})
+ .append(this._renderTagLabel(node));
+ },
+ /**
+ * Render a node, from the arch of the view. It is a generic method, that
+ * will dispatch on specific other methods. The rendering of a node is a
+ * jQuery element (or a string), with the correct classes, attrs, and
+ * content.
+ *
+ * For fields, it will return the $el of the field widget. Note that this
+ * method is synchronous, field widgets are instantiated and appended, but
+ * if they are asynchronous, they register their promises in this.defs, and
+ * the _renderView method will properly wait.
+ *
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement | string}
+ */
+ _renderNode: function (node) {
+ var renderer = this['_renderTag' + _.str.capitalize(node.tag)];
+ if (renderer) {
+ return renderer.call(this, node);
+ }
+ if (node.tag === 'div' && node.attrs.name === 'button_box') {
+ return this._renderButtonBox(node);
+ }
+ if (_.isString(node)) {
+ return node;
+ }
+ return this._renderGenericTag(node);
+ },
+ /**
+ * Renders a 'group' node, which contains 'group' nodes in its children.
+ *
+ * @param {Object} node]
+ * @returns {JQueryElement}
+ */
+ _renderOuterGroup: function (node) {
+ var self = this;
+ var $result = $('<div/>', {class: 'o_group'});
+ var nbCols = parseInt(node.attrs.col, 10) || this.OUTER_GROUP_COL;
+ var colSize = Math.max(1, Math.round(12 / nbCols));
+ if (node.attrs.string) {
+ var $sep = $('<div/>', {class: 'o_horizontal_separator'}).text(node.attrs.string);
+ $result.append($sep);
+ }
+ $result.append(_.map(node.children, function (child) {
+ if (child.tag === 'newline') {
+ return $('<br/>');
+ }
+ var $child = self._renderNode(child);
+ $child.addClass('o_group_col_' + (colSize * (parseInt(child.attrs.colspan, 10) || 1)));
+ return $child;
+ }));
+ this._handleAttributes($result, node);
+ this._registerModifiers(node, this.state, $result);
+ return $result;
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderStatButton: function (node) {
+ var $button = viewUtils.renderButtonFromNode(node, {
+ extraClass: 'oe_stat_button',
+ });
+ $button.append(_.map(node.children, this._renderNode.bind(this)));
+ if (node.attrs.help) {
+ this._addButtonTooltip(node, $button);
+ }
+ this._addOnClickAction($button, node);
+ this._handleAttributes($button, node);
+ this._registerModifiers(node, this.state, $button);
+ return $button;
+ },
+ /**
+ * @private
+ * @param {Array} buttons
+ * @return {jQueryElement}
+ */
+ _renderStatusbarButtons: function (buttons) {
+ var $statusbarButtons = $('<div>', {class: 'o_statusbar_buttons'});
+ buttons.forEach(button => $statusbarButtons.append(button));
+ return $statusbarButtons;
+ },
+ /**
+ * @private
+ * @param {Object} page
+ * @param {string} page_id
+ * @returns {jQueryElement}
+ */
+ _renderTabHeader: function (page, page_id) {
+ var $a = $('<a>', {
+ 'data-toggle': 'tab',
+ disable_anchor: 'true',
+ href: '#' + page_id,
+ class: 'nav-link',
+ role: 'tab',
+ text: page.attrs.string,
+ });
+ return $('<li>', {class: 'nav-item'}).append($a);
+ },
+ /**
+ * @private
+ * @param {Object} page
+ * @param {string} page_id
+ * @returns {jQueryElement}
+ */
+ _renderTabPage: function (page, page_id) {
+ var $result = $('<div class="tab-pane" id="' + page_id + '">');
+ $result.append(_.map(page.children, this._renderNode.bind(this)));
+ return $result;
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderTagButton: function (node) {
+ var $button = viewUtils.renderButtonFromNode(node);
+ $button.append(_.map(node.children, this._renderNode.bind(this)));
+ this._addOnClickAction($button, node);
+ this._handleAttributes($button, node);
+ this._registerModifiers(node, this.state, $button);
+
+ // Display tooltip
+ if (config.isDebug() || node.attrs.help) {
+ this._addButtonTooltip(node, $button);
+ }
+
+ return $button;
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderTagField: function (node) {
+ return this._renderFieldWidget(node, this.state);
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderTagForm: function (node) {
+ var $result = $('<div/>');
+ if (node.attrs.class) {
+ $result.addClass(node.attrs.class);
+ }
+ var allNodes = node.children.map(this._renderNode.bind(this));
+ $result.append(allNodes);
+ return $result;
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderTagGroup: function (node) {
+ var isOuterGroup = _.some(node.children, function (child) {
+ return child.tag === 'group';
+ });
+ if (!isOuterGroup) {
+ return this._renderInnerGroup(node);
+ }
+ return this._renderOuterGroup(node);
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderTagHeader: function (node) {
+ var self = this;
+ var $statusbar = $('<div>', {class: 'o_form_statusbar'});
+ $statusbar.append(this._renderHeaderButtons(node));
+ _.each(node.children, function (child) {
+ if (child.tag === 'field') {
+ var $el = self._renderFieldWidget(child, self.state);
+ $statusbar.append($el);
+ }
+ });
+ this._handleAttributes($statusbar, node);
+ this._registerModifiers(node, this.state, $statusbar);
+ return $statusbar;
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderTagLabel: function (node) {
+ if (!this.renderInvisible && node.tag === 'field' &&
+ node.attrs.modifiers.invisible === true) {
+ // skip rendering of invisible fields/labels
+ return $();
+ }
+ var self = this;
+ var text;
+ let fieldName;
+ if (node.tag === 'label') {
+ fieldName = this.fieldIdsToNames[node.attrs.for]; // 'for' references a <field> node id
+ } else {
+ fieldName = node.attrs.name;
+ }
+ if ('string' in node.attrs) { // allow empty string
+ text = node.attrs.string;
+ } else if (fieldName) {
+ text = this.state.fields[fieldName].string;
+ } else {
+ return this._renderGenericTag(node);
+ }
+ var $result = $('<label>', {
+ class: 'o_form_label',
+ for: this._getIDForLabel(node.tag === 'label' ? node.attrs.for : node.attrs.id),
+ text: text,
+ });
+ if (node.tag === 'label') {
+ this._handleAttributes($result, node);
+ }
+ var modifiersOptions;
+ if (fieldName) {
+ modifiersOptions = {
+ callback: function (element, modifiers, record) {
+ var widgets = self.allFieldWidgets[record.id];
+ var widget = _.findWhere(widgets, {name: fieldName});
+ if (!widget) {
+ return; // FIXME this occurs if the widget is created
+ // after the label (explicit <label/> tag in the
+ // arch), so this won't work on first rendering
+ // only on reevaluation
+ }
+ element.$el.toggleClass('o_form_label_empty', !!( // FIXME condition is evaluated twice (label AND widget...)
+ record.data.id
+ && (modifiers.readonly || self.mode === 'readonly')
+ && !widget.isSet()
+ ));
+ },
+ };
+ }
+ // FIXME if the function is called with a <label/> node, the registered
+ // modifiers will be those on this node. Maybe the desired behavior
+ // would be to merge them with associated field node if any... note:
+ // this worked in 10.0 for "o_form_label_empty" reevaluation but not for
+ // "o_invisible_modifier" reevaluation on labels...
+ this._registerModifiers(node, this.state, $result, modifiersOptions);
+ return $result;
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderTagNotebook: function (node) {
+ var self = this;
+ var $headers = $('<ul class="nav nav-tabs">');
+ var $pages = $('<div class="tab-content">');
+ // renderedTabs is used to aggregate the generated $headers and $pages
+ // alongside their node, so that their modifiers can be registered once
+ // all tabs have been rendered, to ensure that the first visible tab
+ // is correctly activated
+ var renderedTabs = _.map(node.children, function (child, index) {
+ var pageID = _.uniqueId('notebook_page_');
+ var $header = self._renderTabHeader(child, pageID);
+ var $page = self._renderTabPage(child, pageID);
+ self._handleAttributes($header, child);
+ $headers.append($header);
+ $pages.append($page);
+ return {
+ $header: $header,
+ $page: $page,
+ node: child,
+ };
+ });
+ // register the modifiers for each tab
+ _.each(renderedTabs, function (tab) {
+ self._registerModifiers(tab.node, self.state, tab.$header, {
+ callback: function (element, modifiers) {
+ // if the active tab is invisible, activate the first visible tab instead
+ var $link = element.$el.find('.nav-link');
+ if (modifiers.invisible && $link.hasClass('active')) {
+ $link.removeClass('active');
+ tab.$page.removeClass('active');
+ self.inactiveNotebooks.push(renderedTabs);
+ }
+ if (!modifiers.invisible) {
+ // make first page active if there is only one page to display
+ var $visibleTabs = $headers.find('li:not(.o_invisible_modifier)');
+ if ($visibleTabs.length === 1) {
+ self.inactiveNotebooks.push(renderedTabs);
+ }
+ }
+ },
+ });
+ });
+ this._activateFirstVisibleTab(renderedTabs);
+ var $notebookHeaders = $('<div class="o_notebook_headers">').append($headers);
+ var $notebook = $('<div class="o_notebook">').append($notebookHeaders, $pages);
+ $notebook[0].dataset.name = node.attrs.name || '_default_';
+ this._registerModifiers(node, this.state, $notebook);
+ this._handleAttributes($notebook, node);
+ return $notebook;
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderTagSeparator: function (node) {
+ var $separator = $('<div/>').addClass('o_horizontal_separator').text(node.attrs.string);
+ this._handleAttributes($separator, node);
+ this._registerModifiers(node, this.state, $separator);
+ return $separator;
+ },
+ /**
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderTagSheet: function (node) {
+ this.has_sheet = true;
+ var $sheet = $('<div>', {class: 'clearfix position-relative o_form_sheet'});
+ $sheet.append(node.children.map(this._renderNode.bind(this)));
+ return $sheet;
+ },
+ /**
+ * Instantiate custom widgets
+ *
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement}
+ */
+ _renderTagWidget: function (node) {
+ return this._renderWidget(this.state, node);
+ },
+ /**
+ * Main entry point for the rendering. From here, we call _renderNode on
+ * the root of the arch, then, when every promise (from the field widgets)
+ * are done, it will resolves itself.
+ *
+ * @private
+ * @override method from BasicRenderer
+ * @returns {Promise}
+ */
+ _renderView: function () {
+ var self = this;
+
+ // render the form and evaluate the modifiers
+ var defs = [];
+ this.defs = defs;
+ this.inactiveNotebooks = [];
+ var $form = this._renderNode(this.arch).addClass(this.className);
+ delete this.defs;
+
+ return Promise.all(defs).then(() => this.__renderView()).then(function () {
+ self._updateView($form.contents());
+ if (self.state.res_id in self.alertFields) {
+ self.displayTranslationAlert();
+ }
+ }).then(function(){
+ if (self.lastActivatedFieldIndex >= 0) {
+ self._activateNextFieldWidget(self.state, self.lastActivatedFieldIndex);
+ }
+ }).guardedCatch(function () {
+ $form.remove();
+ });
+ },
+ /**
+ * Meant to be overridden if asynchronous work needs to be done when
+ * rendering the view. This is called right before attaching the new view
+ * content.
+ * @private
+ * @returns {Promise<any>}
+ */
+ async __renderView() {},
+ /**
+ * This method is overridden to activate the first notebook page if the
+ * current active page is invisible due to modifiers. This is done after
+ * all modifiers are applied on all page elements.
+ *
+ * @override
+ */
+ async _updateAllModifiers() {
+ await this._super(...arguments);
+ for (const tabs of this.inactiveNotebooks) {
+ this._activateFirstVisibleTab(tabs);
+ }
+ this.inactiveNotebooks = [];
+ },
+ /**
+ * Updates the form's $el with new content.
+ *
+ * @private
+ * @see _renderView
+ * @param {JQuery} $newContent
+ */
+ _updateView: function ($newContent) {
+ var self = this;
+
+ // Set the new content of the form view, and toggle classnames
+ this.$el.html($newContent);
+ this.$el.toggleClass('o_form_nosheet', !this.has_sheet);
+ if (this.has_sheet) {
+ this.$el.children().not('.o_FormRenderer_chatterContainer')
+ .wrapAll($('<div/>', {class: 'o_form_sheet_bg'}));
+ }
+ this.$el.toggleClass('o_form_editable', this.mode === 'edit');
+ this.$el.toggleClass('o_form_readonly', this.mode === 'readonly');
+
+ // Attach the tooltips on the fields' label
+ _.each(this.allFieldWidgets[this.state.id], function (widget) {
+ const idForLabel = self.idsForLabels[widget[symbol]];
+ var $label = idForLabel ? self.$('.o_form_label[for=' + idForLabel + ']') : $();
+ self._addFieldTooltip(widget, $label);
+ if (widget.attrs.widget === 'upgrade_boolean') {
+ // this widget needs a reference to its $label to be correctly
+ // rendered
+ widget.renderWithLabel($label);
+ }
+ });
+ },
+ /**
+ * Sets id attribute of given widget to idForLabel
+ *
+ * @private
+ * @param {AbstractField} widget
+ * @param {idForLabel} string
+ */
+ _setIDForLabel: function (widget, idForLabel) {
+ widget.setIDForLabel(idForLabel);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onActivateNextWidget: function (ev) {
+ ev.stopPropagation();
+ var index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target);
+ this._activateNextFieldWidget(this.state, index);
+ },
+ /**
+ * @override
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNavigationMove: function (ev) {
+ ev.stopPropagation();
+ // We prevent the default behaviour and stop the propagation of the
+ // originalEvent when the originalEvent is a tab keydown to not let
+ // the browser do it. The action is done by this renderer.
+ if (ev.data.originalEvent && ['next', 'previous'].includes(ev.data.direction)) {
+ ev.data.originalEvent.preventDefault();
+ ev.data.originalEvent.stopPropagation();
+ }
+ var index;
+ let target = ev.data.target || ev.target;
+ if (target.__owl__) {
+ target = target.__owl__.parent; // Owl fields are wrapped by the FieldWrapper
+ }
+ if (ev.data.direction === "next") {
+ index = this.allFieldWidgets[this.state.id].indexOf(target);
+ this._activateNextFieldWidget(this.state, index);
+ } else if (ev.data.direction === "previous") {
+ index = this.allFieldWidgets[this.state.id].indexOf(target);
+ this._activatePreviousFieldWidget(this.state, index);
+ }
+ },
+ /**
+ * Listen to notebook tab changes and trigger a DOM_updated event such that
+ * widgets in the visible tab can correctly compute their dimensions (e.g.
+ * autoresize on field text)
+ *
+ * @private
+ */
+ _onNotebookTabChanged: function () {
+ core.bus.trigger('DOM_updated');
+ },
+ /**
+ * open the translation view for the current field
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onTranslate: function (ev) {
+ ev.preventDefault();
+ this.trigger_up('translate', {
+ fieldName: ev.target.name,
+ id: this.state.id,
+ isComingFromTranslationAlert: true,
+ });
+ },
+ /**
+ * remove alert fields of record from alertFields object
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onTranslateNotificationClose: function(ev) {
+ delete this.alertFields[this.state.res_id];
+ },
+});
+
+return FormRenderer;
+});
diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js
new file mode 100644
index 00000000..a7885e0c
--- /dev/null
+++ b/addons/web/static/src/js/views/form/form_view.js
@@ -0,0 +1,201 @@
+odoo.define('web.FormView', function (require) {
+"use strict";
+
+var BasicView = require('web.BasicView');
+var Context = require('web.Context');
+var core = require('web.core');
+var FormController = require('web.FormController');
+var FormRenderer = require('web.FormRenderer');
+const { generateID } = require('web.utils');
+
+var _lt = core._lt;
+
+var FormView = BasicView.extend({
+ config: _.extend({}, BasicView.prototype.config, {
+ Renderer: FormRenderer,
+ Controller: FormController,
+ }),
+ display_name: _lt('Form'),
+ icon: 'fa-edit',
+ multi_record: false,
+ withSearchBar: false,
+ searchMenuTypes: [],
+ viewType: 'form',
+ /**
+ * @override
+ */
+ init: function (viewInfo, params) {
+ var hasActionMenus = params.hasActionMenus;
+ this._super.apply(this, arguments);
+
+ var mode = params.mode || (params.currentId ? 'readonly' : 'edit');
+ this.loadParams.type = 'record';
+
+ // this is kind of strange, but the param object is modified by
+ // AbstractView, so we only need to use its hasActionMenus value if it was
+ // not already present in the beginning of this method
+ if (hasActionMenus === undefined) {
+ hasActionMenus = params.hasActionMenus;
+ }
+ this.controllerParams.hasActionMenus = hasActionMenus;
+ this.controllerParams.disableAutofocus = params.disable_autofocus || this.arch.attrs.disable_autofocus;
+ this.controllerParams.toolbarActions = viewInfo.toolbar;
+ this.controllerParams.footerToButtons = params.footerToButtons;
+
+ var defaultButtons = 'default_buttons' in params ? params.default_buttons : true;
+ this.controllerParams.defaultButtons = defaultButtons;
+ this.controllerParams.mode = mode;
+
+ this.rendererParams.mode = mode;
+ this.rendererParams.isFromFormViewDialog = params.isFromFormViewDialog;
+ this.rendererParams.fieldIdsToNames = this.fieldsView.fieldIdsToNames;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getController: function (parent) {
+ return this._loadSubviews(parent).then(this._super.bind(this, parent));
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _extractParamsFromAction: function (action) {
+ var params = this._super.apply(this, arguments);
+ var inDialog = action.target === 'new';
+ var inline = action.target === 'inline';
+ var fullscreen = action.target === 'fullscreen';
+ params.withControlPanel = !(inDialog || inline);
+ params.footerToButtons = inDialog;
+ params.hasSearchView = inDialog ? false : params.hasSearchView;
+ params.hasActionMenus = !inDialog && !inline;
+ params.searchMenuTypes = inDialog ? [] : params.searchMenuTypes;
+ if (inDialog || inline || fullscreen) {
+ params.mode = 'edit';
+ } else if (action.context && action.context.form_view_initial_mode) {
+ params.mode = action.context.form_view_initial_mode;
+ }
+ return params;
+ },
+ /**
+ * Loads the subviews for x2many fields when they are not inline
+ *
+ * @private
+ * @param {Widget} parent the parent of the model, if it has to be created
+ * @returns {Promise}
+ */
+ _loadSubviews: function (parent) {
+ var self = this;
+ var defs = [];
+ if (this.loadParams && this.loadParams.fieldsInfo) {
+ var fields = this.loadParams.fields;
+
+ _.each(this.loadParams.fieldsInfo.form, function (attrs, fieldName) {
+ var field = fields[fieldName];
+ if (!field) {
+ // when a one2many record is opened in a form view, the fields
+ // of the main one2many view (list or kanban) are added to the
+ // fieldsInfo of its form view, but those fields aren't in the
+ // loadParams.fields, as they are not displayed in the view, so
+ // we can ignore them.
+ return;
+ }
+ if (field.type !== 'one2many' && field.type !== 'many2many') {
+ return;
+ }
+
+ if (attrs.Widget.prototype.useSubview && !attrs.__no_fetch && !attrs.views[attrs.mode]) {
+ var context = {};
+ var regex = /'([a-z]*_view_ref)' *: *'(.*?)'/g;
+ var matches;
+ while (matches = regex.exec(attrs.context)) {
+ context[matches[1]] = matches[2];
+ }
+
+ // Remove *_view_ref coming from parent view
+ var refinedContext = _.pick(self.loadParams.context, function (value, key) {
+ return key.indexOf('_view_ref') === -1;
+ });
+ // Specify the main model to prevent access rights defined in the context
+ // (e.g. create: 0) to apply to subviews. We use here the same logic as
+ // the one applied by the server for inline views.
+ refinedContext.base_model_name = self.controllerParams.modelName;
+ defs.push(parent.loadViews(
+ field.relation,
+ new Context(context, self.userContext, refinedContext).eval(),
+ [[null, attrs.mode === 'tree' ? 'list' : attrs.mode]])
+ .then(function (views) {
+ for (var viewName in views) {
+ // clone to make runbot green?
+ attrs.views[viewName] = self._processFieldsView(views[viewName], viewName);
+ attrs.views[viewName].fields = attrs.views[viewName].viewFields;
+ self._processSubViewAttrs(attrs.views[viewName], attrs);
+ }
+ self._setSubViewLimit(attrs);
+ }));
+ } else {
+ self._setSubViewLimit(attrs);
+ }
+ });
+ }
+ return Promise.all(defs);
+ },
+ /**
+ * @override
+ */
+ _processArch(arch, fv) {
+ fv.fieldIdsToNames = {}; // maps field ids (identifying <field> nodes) to field names
+ return this._super(...arguments);
+ },
+ /**
+ * Override to populate the 'fieldIdsToNames' dict mapping <field> node ids
+ * to field names. Those ids are computed as follows:
+ * - if set on the node, we use the 'id' attribute
+ * - otherwise
+ * - if this is the first occurrence of the field in the arch, we use
+ * its name as id ('name' attribute)
+ * - otherwise we generate an id by concatenating the field name with
+ * a unique id
+ * - in both cases, we set the id we generated in the attrs, as it
+ * will be used by the renderer.
+ *
+ * @override
+ */
+ _processNode(node, fv) {
+ if (node.tag === 'field') {
+ const name = node.attrs.name;
+ let uid = node.attrs.id;
+ if (!uid) {
+ uid = name in fv.fieldIdsToNames ? `${name}__${generateID()}__` : name;
+ node.attrs.id = uid;
+ }
+ fv.fieldIdsToNames[uid] = name;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * We set here the limit for the number of records fetched (in one page).
+ * This method is only called for subviews, not for main views.
+ *
+ * @private
+ * @param {Object} attrs
+ */
+ _setSubViewLimit: function (attrs) {
+ var view = attrs.views && attrs.views[attrs.mode];
+ var limit = view && view.arch.attrs.limit && parseInt(view.arch.attrs.limit, 10);
+ attrs.limit = limit || attrs.Widget.prototype.limit || 40;
+ },
+});
+
+return FormView;
+
+});
diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js
new file mode 100644
index 00000000..6cb2b899
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_controller.js
@@ -0,0 +1,356 @@
+odoo.define('web.GraphController', function (require) {
+"use strict";
+
+/*---------------------------------------------------------
+ * Odoo Graph view
+ *---------------------------------------------------------*/
+
+const AbstractController = require('web.AbstractController');
+const { ComponentWrapper } = require('web.OwlCompatibility');
+const DropdownMenu = require('web.DropdownMenu');
+const { DEFAULT_INTERVAL, INTERVAL_OPTIONS } = require('web.searchUtils');
+const { qweb } = require('web.core');
+const { _t } = require('web.core');
+
+class CarretDropdownMenu extends DropdownMenu {
+ /**
+ * @override
+ */
+ get displayCaret() {
+ return true;
+ }
+}
+
+var GraphController = AbstractController.extend({
+ custom_events: _.extend({}, AbstractController.prototype.custom_events, {
+ item_selected: '_onItemSelected',
+ open_view: '_onOpenView',
+ }),
+
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {GraphModel} model
+ * @param {GraphRenderer} renderer
+ * @param {Object} params
+ * @param {string[]} params.measures
+ * @param {boolean} params.isEmbedded
+ * @param {string[]} params.groupableFields,
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.measures = params.measures;
+ // this parameter condition the appearance of a 'Group By'
+ // button in the control panel owned by the graph view.
+ this.isEmbedded = params.isEmbedded;
+ this.withButtons = params.withButtons;
+ // views to use in the action triggered when the graph is clicked
+ this.views = params.views;
+ this.title = params.title;
+
+ // this parameter determines what is the list of fields
+ // that may be used within the groupby menu available when
+ // the view is embedded
+ this.groupableFields = params.groupableFields;
+ this.buttonDropdownPromises = [];
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$el.addClass('o_graph_controller');
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @todo check if this can be removed (mostly duplicate with
+ * AbstractController method)
+ */
+ destroy: function () {
+ if (this.$buttons) {
+ // remove jquery's tooltip() handlers
+ this.$buttons.find('button').off().tooltip('dispose');
+ }
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns the current mode, measure and groupbys, so we can restore the
+ * view when we save the current state in the search view, or when we add it
+ * to the dashboard.
+ *
+ * @override
+ * @returns {Object}
+ */
+ getOwnedQueryParams: function () {
+ var state = this.model.get();
+ return {
+ context: {
+ graph_measure: state.measure,
+ graph_mode: state.mode,
+ graph_groupbys: state.groupBy,
+ }
+ };
+ },
+ /**
+ * @override
+ */
+ reload: async function () {
+ const promises = [this._super(...arguments)];
+ if (this.withButtons) {
+ const state = this.model.get();
+ this.measures.forEach(m => m.isActive = m.fieldName === state.measure);
+ promises.push(this.measureMenu.update({ items: this.measures }));
+ }
+ return Promise.all(promises);
+ },
+ /**
+ * Render the buttons according to the GraphView.buttons and
+ * add listeners on it.
+ * Set this.$buttons with the produced jQuery element
+ *
+ * @param {jQuery} [$node] a jQuery node where the rendered buttons should
+ * be inserted $node may be undefined, in which case the GraphView does
+ * nothing
+ */
+ renderButtons: function ($node) {
+ this.$buttons = $(qweb.render('GraphView.buttons'));
+ this.$buttons.find('button').tooltip();
+ this.$buttons.click(ev => this._onButtonClick(ev));
+
+ if (this.withButtons) {
+ const state = this.model.get();
+ const fragment = document.createDocumentFragment();
+ // Instantiate and append MeasureMenu
+ this.measures.forEach(m => m.isActive = m.fieldName === state.measure);
+ this.measureMenu = new ComponentWrapper(this, CarretDropdownMenu, {
+ title: _t("Measures"),
+ items: this.measures,
+ });
+ this.buttonDropdownPromises = [this.measureMenu.mount(fragment)];
+ if ($node) {
+ if (this.isEmbedded) {
+ // Instantiate and append GroupBy menu
+ this.groupByMenu = new ComponentWrapper(this, CarretDropdownMenu, {
+ title: _t("Group By"),
+ icon: 'fa fa-bars',
+ items: this._getGroupBys(state.groupBy),
+ });
+ this.buttonDropdownPromises.push(this.groupByMenu.mount(fragment));
+ }
+ this.$buttons.appendTo($node);
+ }
+ }
+ },
+ /**
+ * Makes sure that the buttons in the control panel matches the current
+ * state (so, correct active buttons and stuff like that).
+ *
+ * @override
+ */
+ updateButtons: function () {
+ if (!this.$buttons) {
+ return;
+ }
+ var state = this.model.get();
+ this.$buttons.find('.o_graph_button').removeClass('active');
+ this.$buttons
+ .find('.o_graph_button[data-mode="' + state.mode + '"]')
+ .addClass('active');
+ this.$buttons
+ .find('.o_graph_button[data-mode="stack"]')
+ .data('stacked', state.stacked)
+ .toggleClass('active', state.stacked)
+ .toggleClass('o_hidden', state.mode !== 'bar');
+ this.$buttons
+ .find('.o_graph_button[data-order]')
+ .toggleClass('o_hidden', state.mode === 'pie' || !!Object.keys(state.timeRanges).length)
+ .filter('.o_graph_button[data-order="' + state.orderBy + '"]')
+ .toggleClass('active', !!state.orderBy);
+
+ if (this.withButtons) {
+ this._attachDropdownComponents();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Attaches the different dropdown components to the buttons container.
+ *
+ * @returns {Promise}
+ */
+ async _attachDropdownComponents() {
+ await Promise.all(this.buttonDropdownPromises);
+ const actionsContainer = this.$buttons[0];
+ // Attach "measures" button
+ actionsContainer.appendChild(this.measureMenu.el);
+ this.measureMenu.el.classList.add('o_graph_measures_list');
+ if (this.isEmbedded) {
+ // Attach "groupby" button
+ actionsContainer.appendChild(this.groupByMenu.el);
+ this.groupByMenu.el.classList.add('o_group_by_menu');
+ }
+ // Update button classes accordingly to the current mode
+ const buttons = actionsContainer.querySelectorAll('.o_dropdown_toggler_btn');
+ for (const button of buttons) {
+ button.classList.remove('o_dropdown_toggler_btn', 'btn-secondary');
+ if (this.isEmbedded) {
+ button.classList.add('btn-outline-secondary');
+ } else {
+ button.classList.add('btn-primary');
+ button.tabIndex = 0;
+ }
+ }
+ },
+
+ /**
+ * Returns the items used by the Group By menu in embedded mode.
+ *
+ * @private
+ * @param {string[]} activeGroupBys
+ * @returns {Object[]}
+ */
+ _getGroupBys(activeGroupBys) {
+ const normalizedGroupBys = this._normalizeActiveGroupBys(activeGroupBys);
+ const groupBys = Object.keys(this.groupableFields).map(fieldName => {
+ const field = this.groupableFields[fieldName];
+ const groupByActivity = normalizedGroupBys.filter(gb => gb.fieldName === fieldName);
+ const groupBy = {
+ id: fieldName,
+ isActive: Boolean(groupByActivity.length),
+ description: field.string,
+ itemType: 'groupBy',
+ };
+ if (['date', 'datetime'].includes(field.type)) {
+ groupBy.hasOptions = true;
+ const activeOptionIds = groupByActivity.map(gb => gb.interval);
+ groupBy.options = Object.values(INTERVAL_OPTIONS).map(o => {
+ return Object.assign({}, o, { isActive: activeOptionIds.includes(o.id) });
+ });
+ }
+ return groupBy;
+ }).sort((gb1, gb2) => {
+ return gb1.description.localeCompare(gb2.description);
+ });
+ return groupBys;
+ },
+
+ /**
+ * This method puts the active groupBys in a convenient form.
+ *
+ * @private
+ * @param {string[]} activeGroupBys
+ * @returns {Object[]} normalizedGroupBys
+ */
+ _normalizeActiveGroupBys(activeGroupBys) {
+ return activeGroupBys.map(groupBy => {
+ const fieldName = groupBy.split(':')[0];
+ const field = this.groupableFields[fieldName];
+ const normalizedGroupBy = { fieldName };
+ if (['date', 'datetime'].includes(field.type)) {
+ normalizedGroupBy.interval = groupBy.split(':')[1] || DEFAULT_INTERVAL;
+ }
+ return normalizedGroupBy;
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Do what need to be done when a button from the control panel is clicked.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onButtonClick: function (ev) {
+ var $target = $(ev.target);
+ if ($target.hasClass('o_graph_button')) {
+ if (_.contains(['bar','line', 'pie'], $target.data('mode'))) {
+ this.update({ mode: $target.data('mode') });
+ } else if ($target.data('mode') === 'stack') {
+ this.update({ stacked: !$target.data('stacked') });
+ } else if (['asc', 'desc'].includes($target.data('order'))) {
+ const order = $target.data('order');
+ const state = this.model.get();
+ this.update({ orderBy: state.orderBy === order ? false : order });
+ }
+ }
+ },
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onItemSelected(ev) {
+ const item = ev.data.item;
+ if (this.isEmbedded && item.itemType === 'groupBy') {
+ const fieldName = item.id;
+ const optionId = ev.data.option && ev.data.option.id;
+ const activeGroupBys = this.model.get().groupBy;
+ if (optionId) {
+ const normalizedGroupBys = this._normalizeActiveGroupBys(activeGroupBys);
+ const index = normalizedGroupBys.findIndex(ngb =>
+ ngb.fieldName === fieldName && ngb.interval === optionId);
+ if (index === -1) {
+ activeGroupBys.push(fieldName + ':' + optionId);
+ } else {
+ activeGroupBys.splice(index, 1);
+ }
+ } else {
+ const groupByFieldNames = activeGroupBys.map(gb => gb.split(':')[0]);
+ const indexOfGroupby = groupByFieldNames.indexOf(fieldName);
+ if (indexOfGroupby === -1) {
+ activeGroupBys.push(fieldName);
+ } else {
+ activeGroupBys.splice(indexOfGroupby, 1);
+ }
+ }
+ this.update({ groupBy: activeGroupBys });
+ this.groupByMenu.update({
+ items: this._getGroupBys(activeGroupBys),
+ });
+ } else if (item.itemType === 'measure') {
+ this.update({ measure: item.fieldName });
+ this.measures.forEach(m => m.isActive = m.fieldName === item.fieldName);
+ this.measureMenu.update({ items: this.measures });
+ }
+ },
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Array[]} ev.data.domain
+ */
+ _onOpenView(ev) {
+ ev.stopPropagation();
+ const state = this.model.get();
+ const context = Object.assign({}, state.context);
+ Object.keys(context).forEach(x => {
+ if (x === 'group_by' || x.startsWith('search_default_')) {
+ delete context[x];
+ }
+ });
+ this.do_action({
+ context: context,
+ domain: ev.data.domain,
+ name: this.title,
+ res_model: this.modelName,
+ target: 'current',
+ type: 'ir.actions.act_window',
+ view_mode: 'list',
+ views: this.views,
+ });
+ },
+});
+
+return GraphController;
+
+});
diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js
new file mode 100644
index 00000000..b1bcddb4
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_model.js
@@ -0,0 +1,322 @@
+odoo.define('web.GraphModel', function (require) {
+"use strict";
+
+var core = require('web.core');
+const { DEFAULT_INTERVAL, rankInterval } = require('web.searchUtils');
+
+var _t = core._t;
+
+/**
+ * The graph model is responsible for fetching and processing data from the
+ * server. It basically just do a(some) read_group(s) and format/normalize data.
+ */
+var AbstractModel = require('web.AbstractModel');
+
+return AbstractModel.extend({
+ /**
+ * @override
+ * @param {Widget} parent
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.chart = null;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ *
+ * We defend against outside modifications by extending the chart data. It
+ * may be overkill.
+ *
+ * @override
+ * @returns {Object}
+ */
+ __get: function () {
+ return Object.assign({ isSample: this.isSampleModel }, this.chart);
+ },
+ /**
+ * Initial loading.
+ *
+ * @todo All the work to fall back on the graph_groupbys keys in the context
+ * should be done by the graphView I think.
+ *
+ * @param {Object} params
+ * @param {Object} params.context
+ * @param {Object} params.fields
+ * @param {string[]} params.domain
+ * @param {string[]} params.groupBys a list of valid field names
+ * @param {string[]} params.groupedBy a list of valid field names
+ * @param {boolean} params.stacked
+ * @param {string} params.measure a valid field name
+ * @param {'pie'|'bar'|'line'} params.mode
+ * @param {string} params.modelName
+ * @param {Object} params.timeRanges
+ * @returns {Promise} The promise does not return a handle, we don't need
+ * to keep track of various entities.
+ */
+ __load: function (params) {
+ var groupBys = params.context.graph_groupbys || params.groupBys;
+ this.initialGroupBys = groupBys;
+ this.fields = params.fields;
+ this.modelName = params.modelName;
+ this.chart = Object.assign({
+ context: params.context,
+ dataPoints: [],
+ domain: params.domain,
+ groupBy: params.groupedBy.length ? params.groupedBy : groupBys,
+ measure: params.context.graph_measure || params.measure,
+ mode: params.context.graph_mode || params.mode,
+ origins: [],
+ stacked: params.stacked,
+ timeRanges: params.timeRanges,
+ orderBy: params.orderBy
+ });
+
+ this._computeDerivedParams();
+
+ return this._loadGraph();
+ },
+ /**
+ * Reload data. It is similar to the load function. Note that we ignore the
+ * handle parameter, we always expect our data to be in this.chart object.
+ *
+ * @todo This method takes 'groupBy' and load method takes 'groupedBy'. This
+ * is insane.
+ *
+ * @param {any} handle ignored!
+ * @param {Object} params
+ * @param {boolean} [params.stacked]
+ * @param {Object} [params.context]
+ * @param {string[]} [params.domain]
+ * @param {string[]} [params.groupBy]
+ * @param {string} [params.measure] a valid field name
+ * @param {string} [params.mode] one of 'bar', 'pie', 'line'
+ * @param {Object} [params.timeRanges]
+ * @returns {Promise}
+ */
+ __reload: function (handle, params) {
+ if ('context' in params) {
+ this.chart.context = params.context;
+ this.chart.groupBy = params.context.graph_groupbys || this.chart.groupBy;
+ this.chart.measure = params.context.graph_measure || this.chart.measure;
+ this.chart.mode = params.context.graph_mode || this.chart.mode;
+ }
+ if ('domain' in params) {
+ this.chart.domain = params.domain;
+ }
+ if ('groupBy' in params) {
+ this.chart.groupBy = params.groupBy.length ? params.groupBy : this.initialGroupBys;
+ }
+ if ('measure' in params) {
+ this.chart.measure = params.measure;
+ }
+ if ('timeRanges' in params) {
+ this.chart.timeRanges = params.timeRanges;
+ }
+
+ this._computeDerivedParams();
+
+ if ('mode' in params) {
+ this.chart.mode = params.mode;
+ return Promise.resolve();
+ }
+ if ('stacked' in params) {
+ this.chart.stacked = params.stacked;
+ return Promise.resolve();
+ }
+ if ('orderBy' in params) {
+ this.chart.orderBy = params.orderBy;
+ return Promise.resolve();
+ }
+ return this._loadGraph();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Compute this.chart.processedGroupBy, this.chart.domains, this.chart.origins,
+ * and this.chart.comparisonFieldIndex.
+ * Those parameters are determined by this.chart.timeRanges, this.chart.groupBy, and this.chart.domain.
+ *
+ * @private
+ */
+ _computeDerivedParams: function () {
+ this.chart.processedGroupBy = this._processGroupBy(this.chart.groupBy);
+
+ const { range, rangeDescription, comparisonRange, comparisonRangeDescription, fieldName } = this.chart.timeRanges;
+ if (range) {
+ this.chart.domains = [
+ this.chart.domain.concat(range),
+ this.chart.domain.concat(comparisonRange),
+ ];
+ this.chart.origins = [rangeDescription, comparisonRangeDescription];
+ const groupBys = this.chart.processedGroupBy.map(function (gb) {
+ return gb.split(":")[0];
+ });
+ this.chart.comparisonFieldIndex = groupBys.indexOf(fieldName);
+ } else {
+ this.chart.domains = [this.chart.domain];
+ this.chart.origins = [""];
+ this.chart.comparisonFieldIndex = -1;
+ }
+ },
+ /**
+ * @override
+ */
+ _isEmpty() {
+ return this.chart.dataPoints.length === 0;
+ },
+ /**
+ * Fetch and process graph data. It is basically a(some) read_group(s)
+ * with correct fields for each domain. We have to do some light processing
+ * to separate date groups in the field list, because they can be defined
+ * with an aggregation function, such as my_date:week.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _loadGraph: function () {
+ var self = this;
+ this.chart.dataPoints = [];
+ var groupBy = this.chart.processedGroupBy;
+ var fields = _.map(groupBy, function (groupBy) {
+ return groupBy.split(':')[0];
+ });
+
+ if (this.chart.measure !== '__count__') {
+ if (this.fields[this.chart.measure].type === 'many2one') {
+ fields = fields.concat(this.chart.measure + ":count_distinct");
+ }
+ else {
+ fields = fields.concat(this.chart.measure);
+ }
+ }
+
+ var context = _.extend({fill_temporal: true}, this.chart.context);
+
+ var proms = [];
+ this.chart.domains.forEach(function (domain, originIndex) {
+ proms.push(self._rpc({
+ model: self.modelName,
+ method: 'read_group',
+ context: context,
+ domain: domain,
+ fields: fields,
+ groupBy: groupBy,
+ lazy: false,
+ }).then(self._processData.bind(self, originIndex)));
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * Since read_group is insane and returns its result on different keys
+ * depending of some input, we have to normalize the result.
+ * Each group coming from the read_group produces a dataPoint
+ *
+ * @todo This is not good for race conditions. The processing should get
+ * the object this.chart in argument, or an array or something. We want to
+ * avoid writing on a this.chart object modified by a subsequent read_group
+ *
+ * @private
+ * @param {number} originIndex
+ * @param {any} rawData result from the read_group
+ */
+ _processData: function (originIndex, rawData) {
+ var self = this;
+ var isCount = this.chart.measure === '__count__';
+ var labels;
+
+ function getLabels (dataPt) {
+ return self.chart.processedGroupBy.map(function (field) {
+ return self._sanitizeValue(dataPt[field], field.split(":")[0]);
+ });
+ }
+ rawData.forEach(function (dataPt){
+ labels = getLabels(dataPt);
+ var count = dataPt.__count || dataPt[self.chart.processedGroupBy[0]+'_count'] || 0;
+ var value = isCount ? count : dataPt[self.chart.measure];
+ if (value instanceof Array) {
+ // when a many2one field is used as a measure AND as a grouped
+ // field, bad things happen. The server will only return the
+ // grouped value and will not aggregate it. Since there is a
+ // name clash, we are then in the situation where this value is
+ // an array. Fortunately, if we group by a field, then we can
+ // say for certain that the group contains exactly one distinct
+ // value for that field.
+ value = 1;
+ }
+ self.chart.dataPoints.push({
+ resId: dataPt[self.chart.groupBy[0]] instanceof Array ? dataPt[self.chart.groupBy[0]][0] : -1,
+ count: count,
+ domain: dataPt.__domain,
+ value: value,
+ labels: labels,
+ originIndex: originIndex,
+ });
+ });
+ },
+ /**
+ * Process the groupBy parameter in order to keep only the finer interval option for
+ * elements based on date/datetime field (e.g. 'date:year'). This means that
+ * 'week' is prefered to 'month'. The field stays at the place of its first occurence.
+ * For instance,
+ * ['foo', 'date:month', 'bar', 'date:week'] becomes ['foo', 'date:week', 'bar'].
+ *
+ * @private
+ * @param {string[]} groupBy
+ * @returns {string[]}
+ */
+ _processGroupBy: function(groupBy) {
+ const groupBysMap = new Map();
+ for (const gb of groupBy) {
+ let [fieldName, interval] = gb.split(':');
+ const field = this.fields[fieldName];
+ if (['date', 'datetime'].includes(field.type)) {
+ interval = interval || DEFAULT_INTERVAL;
+ }
+ if (groupBysMap.has(fieldName)) {
+ const registeredInterval = groupBysMap.get(fieldName);
+ if (rankInterval(registeredInterval) < rankInterval(interval)) {
+ groupBysMap.set(fieldName, interval);
+ }
+ } else {
+ groupBysMap.set(fieldName, interval);
+ }
+ }
+ return [...groupBysMap].map(([fieldName, interval]) => {
+ if (interval) {
+ return `${fieldName}:${interval}`;
+ }
+ return fieldName;
+ });
+ },
+ /**
+ * Helper function (for _processData), turns various values in a usable
+ * string form, that we can display in the interface.
+ *
+ * @private
+ * @param {any} value value for the field fieldName received by the read_group rpc
+ * @param {string} fieldName
+ * @returns {string}
+ */
+ _sanitizeValue: function (value, fieldName) {
+ if (value === false && this.fields[fieldName].type !== 'boolean') {
+ return _t("Undefined");
+ }
+ if (value instanceof Array) {
+ return value[1];
+ }
+ if (fieldName && (this.fields[fieldName].type === 'selection')) {
+ var selected = _.where(this.fields[fieldName].selection, {0: value})[0];
+ return selected ? selected[1] : value;
+ }
+ return value;
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js
new file mode 100644
index 00000000..118245f9
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_renderer.js
@@ -0,0 +1,1099 @@
+odoo.define('web.GraphRenderer', function (require) {
+"use strict";
+
+/**
+ * The graph renderer turns the data from the graph model into a nice looking
+ * canvas chart. This code uses the Chart.js library.
+ */
+
+var AbstractRenderer = require('web.AbstractRenderer');
+var config = require('web.config');
+var core = require('web.core');
+var dataComparisonUtils = require('web.dataComparisonUtils');
+var fieldUtils = require('web.field_utils');
+
+var _t = core._t;
+var DateClasses = dataComparisonUtils.DateClasses;
+var qweb = core.qweb;
+
+var CHART_TYPES = ['pie', 'bar', 'line'];
+
+var COLORS = ["#1f77b4", "#ff7f0e", "#aec7e8", "#ffbb78", "#2ca02c", "#98df8a", "#d62728",
+ "#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2",
+ "#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"];
+var COLOR_NB = COLORS.length;
+
+function hexToRGBA(hex, opacity) {
+ var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ var rgb = result.slice(1, 4).map(function (n) {
+ return parseInt(n, 16);
+ }).join(',');
+ return 'rgba(' + rgb + ',' + opacity + ')';
+}
+
+// used to format values in tooltips and yAxes.
+var FORMAT_OPTIONS = {
+ // allow to decide if utils.human_number should be used
+ humanReadable: function (value) {
+ return Math.abs(value) >= 1000;
+ },
+ // with the choices below, 1236 is represented by 1.24k
+ minDigits: 1,
+ decimals: 2,
+ // avoid comma separators for thousands in numbers when human_number is used
+ formatterCallback: function (str) {
+ return str;
+ },
+};
+
+var NO_DATA = [_t('No data')];
+NO_DATA.isNoData = true;
+
+// hide top legend when too many items for device size
+var MAX_LEGEND_LENGTH = 4 * (Math.max(1, config.device.size_class));
+
+return AbstractRenderer.extend({
+ className: "o_graph_renderer",
+ sampleDataTargets: ['.o_graph_canvas_container'],
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Object} state
+ * @param {Object} params
+ * @param {boolean} [params.isEmbedded]
+ * @param {Object} [params.fields]
+ * @param {string} [params.title]
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this.isEmbedded = params.isEmbedded || false;
+ this.title = params.title || '';
+ this.fields = params.fields || {};
+ this.disableLinking = params.disableLinking;
+
+ this.chart = null;
+ this.chartId = _.uniqueId('chart');
+ this.$legendTooltip = null;
+ this.$tooltip = null;
+ },
+ /**
+ * Chart.js does not need the canvas to be in dom in order
+ * to be able to work well. We could avoid the calls to on_attach_callback
+ * and on_detach_callback.
+ *
+ * @override
+ */
+ on_attach_callback: function () {
+ this._super.apply(this, arguments);
+ this.isInDOM = true;
+ this._render();
+ },
+ /**
+ * @override
+ */
+ on_detach_callback: function () {
+ this._super.apply(this, arguments);
+ this.isInDOM = false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * This function aims to remove a suitable number of lines from the tooltip in order to make it reasonably visible.
+ * A message indicating the number of lines is added if necessary.
+ *
+ * @private
+ * @param {Number} maxTooltipHeight this the max height in pixels of the tooltip
+ */
+ _adjustTooltipHeight: function (maxTooltipHeight) {
+ var sizeOneLine = this.$tooltip.find('tbody tr')[0].clientHeight;
+ var tbodySize = this.$tooltip.find('tbody')[0].clientHeight;
+ var toKeep = Math.floor((maxTooltipHeight - (this.$tooltip[0].clientHeight - tbodySize)) / sizeOneLine) - 1;
+ var $lines = this.$tooltip.find('tbody tr');
+ var toRemove = $lines.length - toKeep;
+ if (toRemove > 0) {
+ $lines.slice(toKeep).remove();
+ var tr = document.createElement('tr');
+ var td = document.createElement('td');
+ tr.classList.add('o_show_more');
+ td.innerHTML = _t("...");
+ tr.appendChild(td);
+ this.$tooltip.find('tbody').append(tr);
+ }
+ },
+ /**
+ * This function creates a custom HTML tooltip.
+ *
+ * @private
+ * @param {Object} tooltipModel see chartjs documentation
+ */
+ _customTooltip: function (tooltipModel) {
+ this.$el.css({ cursor: 'default' });
+ if (this.$tooltip) {
+ this.$tooltip.remove();
+ }
+ if (tooltipModel.opacity === 0) {
+ return;
+ }
+ if (tooltipModel.dataPoints.length === 0) {
+ return;
+ }
+
+ if (this._isRedirectionEnabled()) {
+ this.$el.css({ cursor: 'pointer' });
+ }
+
+ const chartArea = this.chart.chartArea;
+ const chartAreaLeft = chartArea.left;
+ const chartAreaRight = chartArea.right;
+ const chartAreaTop = chartArea.top;
+ const rendererTop = this.$el[0].getBoundingClientRect().top;
+
+ const maxTooltipLabelWidth = Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px';
+
+ const tooltipItems = this._getTooltipItems(tooltipModel);
+
+ this.$tooltip = $(qweb.render('GraphView.CustomTooltip', {
+ measure: this.fields[this.state.measure].string,
+ tooltipItems: tooltipItems,
+ maxWidth: maxTooltipLabelWidth,
+ })).css({top: '2px', left: '2px'});
+ const $container = this.$el.find('.o_graph_canvas_container');
+ $container.append(this.$tooltip);
+
+ let top;
+ const tooltipHeight = this.$tooltip[0].clientHeight;
+ const minTopAllowed = Math.floor(chartAreaTop);
+ const maxTopAllowed = Math.floor(window.innerHeight - rendererTop - tooltipHeight) - 2;
+ const y = Math.floor(tooltipModel.y);
+ if (minTopAllowed <= maxTopAllowed) {
+ // Here we know that the full tooltip can fit in the screen.
+ // We put it in the position where Chart.js would put it
+ // if two conditions are respected:
+ // 1: the tooltip is not cut (because we know it is possible to not cut it)
+ // 2: the tooltip does not hide the legend.
+ // If it is not possible to use the Chart.js proposition (y)
+ // we use the best approximated value.
+ if (y <= maxTopAllowed) {
+ if (y >= minTopAllowed) {
+ top = y;
+ } else {
+ top = minTopAllowed;
+ }
+ } else {
+ top = maxTopAllowed;
+ }
+ } else {
+ // Here we know that we cannot satisfy condition 1 above,
+ // so we position the tooltip at the minimal position and
+ // cut it the minimum possible.
+ top = minTopAllowed;
+ const maxTooltipHeight = window.innerHeight - (rendererTop + chartAreaTop) -2;
+ this._adjustTooltipHeight(maxTooltipHeight);
+ }
+ this.$tooltip[0].style.top = Math.floor(top) + 'px';
+
+ this._fixTooltipLeftPosition(this.$tooltip[0], tooltipModel.x);
+ },
+ /**
+ * Filter out some dataPoints because they would lead to bad graphics.
+ * The filtering is done with respect to the graph view mode.
+ * Note that the method does not alter this.state.dataPoints, since we
+ * want to be able to change of mode without fetching data again:
+ * we simply present the same data in a different way.
+ *
+ * @private
+ * @returns {Object[]}
+ */
+ _filterDataPoints: function () {
+ var dataPoints = [];
+ if (_.contains(['bar', 'pie'], this.state.mode)) {
+ dataPoints = this.state.dataPoints.filter(function (dataPt) {
+ return dataPt.count > 0;
+ });
+ } else if (this.state.mode === 'line') {
+ var counts = 0;
+ this.state.dataPoints.forEach(function (dataPt) {
+ if (dataPt.labels[0] !== _t("Undefined")) {
+ dataPoints.push(dataPt);
+ }
+ counts += dataPt.count;
+ });
+ // data points with zero count might have been created on purpose
+ // we only remove them if there are no data point with positive count
+ if (counts === 0) {
+ dataPoints = [];
+ }
+ }
+ return dataPoints;
+ },
+ /**
+ * Sets best left position of a tooltip approaching the proposal x
+ *
+ * @private
+ * @param {DOMElement} tooltip
+ * @param {number} x, left offset proposed
+ */
+ _fixTooltipLeftPosition: function (tooltip, x) {
+ let left;
+ const tooltipWidth = tooltip.clientWidth;
+ const minLeftAllowed = Math.floor(this.chart.chartArea.left + 2);
+ const maxLeftAllowed = Math.floor(this.chart.chartArea.right - tooltipWidth -2);
+ x = Math.floor(x);
+ if (x <= maxLeftAllowed) {
+ if (x >= minLeftAllowed) {
+ left = x;
+ } else {
+ left = minLeftAllowed;
+ }
+ } else {
+ left = maxLeftAllowed;
+ }
+ tooltip.style.left = left + 'px';
+ },
+ /**
+ * Used to format correctly the values in tooltips and yAxes
+ *
+ * @private
+ * @param {number} value
+ * @returns {string} The value formatted using fieldUtils.format.float
+ */
+ _formatValue: function (value) {
+ var measureField = this.fields[this.state.measure];
+ var formatter = fieldUtils.format.float;
+ var formatedValue = formatter(value, measureField, FORMAT_OPTIONS);
+ return formatedValue;
+ },
+ /**
+ * Used any time we need a new color in our charts.
+ *
+ * @private
+ * @param {number} index
+ * @returns {string} a color in HEX format
+ */
+ _getColor: function (index) {
+ return COLORS[index % COLOR_NB];
+ },
+ /**
+ * Determines the initial section of the labels array
+ * over a dataset has to be completed. The section only depends
+ * on the datasets origins.
+ *
+ * @private
+ * @param {number} originIndex
+ * @param {number} defaultLength
+ * @returns {number}
+ */
+ _getDatasetDataLength: function (originIndex, defaultLength) {
+ if (_.contains(['bar', 'line'], this.state.mode) && this.state.comparisonFieldIndex === 0) {
+ return this.dateClasses.dateSets[originIndex].length;
+ }
+ return defaultLength;
+ },
+ /**
+ * Determines to which dataset belong the data point
+ *
+ * @private
+ * @param {Object} dataPt
+ * @returns {string}
+ */
+ _getDatasetLabel: function (dataPt) {
+ if (_.contains(['bar', 'line'], this.state.mode)) {
+ // ([origin] + second to last groupBys) or measure
+ var datasetLabel = dataPt.labels.slice(1).join("/");
+ if (this.state.origins.length > 1) {
+ datasetLabel = this.state.origins[dataPt.originIndex] +
+ (datasetLabel ? ('/' + datasetLabel) : '');
+ }
+ datasetLabel = datasetLabel || this.fields[this.state.measure].string;
+ return datasetLabel;
+ }
+ return this.state.origins[dataPt.originIndex];
+ },
+ /**
+ * Returns an object used to style chart elements independently from the datasets.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getElementOptions: function () {
+ var elementOptions = {};
+ if (this.state.mode === 'bar') {
+ elementOptions.rectangle = {borderWidth: 1};
+ } else if (this.state.mode === 'line') {
+ elementOptions.line = {
+ tension: 0,
+ fill: false,
+ };
+ }
+ return elementOptions;
+ },
+ /**
+ * Returns a DateClasses instance used to manage equivalence of dates.
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ * @returns {DateClasses}
+ */
+ _getDateClasses: function (dataPoints) {
+ var self = this;
+ var dateSets = this.state.origins.map(function () {
+ return [];
+ });
+ dataPoints.forEach(function (dataPt) {
+ dateSets[dataPt.originIndex].push(dataPt.labels[self.state.comparisonFieldIndex]);
+ });
+ dateSets = dateSets.map(function (dateSet) {
+ return _.uniq(dateSet);
+ });
+ return new DateClasses(dateSets);
+ },
+ /**
+ * Determines over which label is the data point
+ *
+ * @private
+ * @param {Object} dataPt
+ * @returns {Array}
+ */
+ _getLabel: function (dataPt) {
+ var i = this.state.comparisonFieldIndex;
+ if (_.contains(['bar', 'line'], this.state.mode)) {
+ if (i === 0) {
+ return [this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i])];
+ } else {
+ return dataPt.labels.slice(0, 1);
+ }
+ } else if (i === 0) {
+ return Array.prototype.concat.apply([], [
+ this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i]),
+ dataPt.labels.slice(i+1)
+ ]);
+ } else {
+ return dataPt.labels;
+ }
+ },
+ /**
+ * Returns the options used to generate the chart legend.
+ *
+ * @private
+ * @param {Number} datasetsCount
+ * @returns {Object}
+ */
+ _getLegendOptions: function (datasetsCount) {
+ var legendOptions = {
+ display: datasetsCount <= MAX_LEGEND_LENGTH,
+ // position: this.state.mode === 'pie' ? 'right' : 'top',
+ position: 'top',
+ onHover: this._onlegendTooltipHover.bind(this),
+ onLeave: this._onLegendTootipLeave.bind(this),
+ };
+ var self = this;
+ if (_.contains(['bar', 'line'], this.state.mode)) {
+ var referenceColor;
+ if (this.state.mode === 'bar') {
+ referenceColor = 'backgroundColor';
+ } else {
+ referenceColor = 'borderColor';
+ }
+ legendOptions.labels = {
+ generateLabels: function (chart) {
+ var data = chart.data;
+ return data.datasets.map(function (dataset, i) {
+ return {
+ text: self._shortenLabel(dataset.label),
+ fullText: dataset.label,
+ fillStyle: dataset[referenceColor],
+ hidden: !chart.isDatasetVisible(i),
+ lineCap: dataset.borderCapStyle,
+ lineDash: dataset.borderDash,
+ lineDashOffset: dataset.borderDashOffset,
+ lineJoin: dataset.borderJoinStyle,
+ lineWidth: dataset.borderWidth,
+ strokeStyle: dataset[referenceColor],
+ pointStyle: dataset.pointStyle,
+ datasetIndex: i,
+ };
+ });
+ },
+ };
+ } else {
+ legendOptions.labels = {
+ generateLabels: function (chart) {
+ var data = chart.data;
+ var metaData = data.datasets.map(function (dataset, index) {
+ return chart.getDatasetMeta(index).data;
+ });
+ return data.labels.map(function (label, i) {
+ var hidden = metaData.reduce(
+ function (hidden, data) {
+ if (data[i]) {
+ hidden = hidden || data[i].hidden;
+ }
+ return hidden;
+ },
+ false
+ );
+ var fullText = self._relabelling(label);
+ var text = self._shortenLabel(fullText);
+ return {
+ text: text,
+ fullText: fullText,
+ fillStyle: label.isNoData ? '#d3d3d3' : self._getColor(i),
+ hidden: hidden,
+ index: i,
+ };
+ });
+ },
+ };
+ }
+ return legendOptions;
+ },
+ /**
+ * Returns the options used to generate the chart axes.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getScaleOptions: function () {
+ var self = this;
+ if (_.contains(['bar', 'line'], this.state.mode)) {
+ return {
+ xAxes: [{
+ type: 'category',
+ scaleLabel: {
+ display: this.state.processedGroupBy.length && !this.isEmbedded,
+ labelString: this.state.processedGroupBy.length ?
+ this.fields[this.state.processedGroupBy[0].split(':')[0]].string : '',
+ },
+ ticks: {
+ // don't use bind: callback is called with 'index' as second parameter
+ // with value labels.indexOf(label)!
+ callback: function (label) {
+ return self._relabelling(label);
+ },
+ },
+ }],
+ yAxes: [{
+ type: 'linear',
+ scaleLabel: {
+ display: !this.isEmbedded,
+ labelString: this.fields[this.state.measure].string,
+ },
+ ticks: {
+ callback: this._formatValue.bind(this),
+ suggestedMax: 0,
+ suggestedMin: 0,
+ }
+ }],
+ };
+ }
+ return {};
+ },
+ /**
+ * Extracts the important information from a tooltipItem generated by Charts.js
+ * (a tooltip item corresponds to a line (different from measure name) of a tooltip)
+ *
+ * @private
+ * @param {Object} item
+ * @param {Object} data
+ * @returns {Object}
+ */
+ _getTooltipItemContent: function (item, data) {
+ var dataset = data.datasets[item.datasetIndex];
+ var label = data.labels[item.index];
+ var value;
+ var boxColor;
+ if (this.state.mode === 'bar') {
+ label = this._relabelling(label, dataset.originIndex);
+ if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) {
+ label = label + "/" + dataset.label;
+ }
+ value = this._formatValue(item.yLabel);
+ boxColor = dataset.backgroundColor;
+ } else if (this.state.mode === 'line') {
+ label = this._relabelling(label, dataset.originIndex);
+ if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) {
+ label = label + "/" + dataset.label;
+ }
+ value = this._formatValue(item.yLabel);
+ boxColor = dataset.borderColor;
+ } else {
+ if (label.isNoData) {
+ value = this._formatValue(0);
+ } else {
+ value = this._formatValue(dataset.data[item.index]);
+ }
+ label = this._relabelling(label, dataset.originIndex);
+ if (this.state.origins.length > 1) {
+ label = dataset.label + "/" + label;
+ }
+ boxColor = dataset.backgroundColor[item.index];
+ }
+ return {
+ label: label,
+ value: value,
+ boxColor: boxColor,
+ };
+ },
+ /**
+ * This function extracts the information from the data points in tooltipModel.dataPoints
+ * (corresponding to datapoints over a given label determined by the mouse position)
+ * that will be displayed in a custom tooltip.
+ *
+ * @private
+ * @param {Object} tooltipModel see chartjs documentation
+ * @return {Object[]}
+ */
+ _getTooltipItems: function (tooltipModel) {
+ var self = this;
+ var data = this.chart.config.data;
+
+ var orderedItems = tooltipModel.dataPoints.sort(function (dPt1, dPt2) {
+ return dPt2.yLabel - dPt1.yLabel;
+ });
+ return orderedItems.reduce(
+ function (acc, item) {
+ acc.push(self._getTooltipItemContent(item, data));
+ return acc;
+ },
+ []
+ );
+ },
+ /**
+ * Returns the options used to generate chart tooltips.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getTooltipOptions: function () {
+ var tooltipOptions = {
+ // disable Chart.js tooltips
+ enabled: false,
+ custom: this._customTooltip.bind(this),
+ };
+ if (this.state.mode === 'line') {
+ tooltipOptions.mode = 'index';
+ tooltipOptions.intersect = false;
+ }
+ return tooltipOptions;
+ },
+ /**
+ * Returns true iff the current graph can be clicked on to redirect to the
+ * list of records.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _isRedirectionEnabled: function () {
+ return !this.disableLinking &&
+ (this.state.mode === 'bar' || this.state.mode === 'pie');
+ },
+ /**
+ * Return the first index of the array list where label can be found
+ * or -1.
+ *
+ * @private
+ * @param {Array[]} list
+ * @param {Array} label
+ * @returns {number}
+ */
+ _indexOf: function (list, label) {
+ var index = -1;
+ for (var j = 0; j < list.length; j++) {
+ var otherLabel = list[j];
+ if (label.length === otherLabel.length) {
+ var equal = true;
+ for (var i = 0; i < label.length; i++) {
+ if (label[i] !== otherLabel[i]) {
+ equal = false;
+ }
+ }
+ if (equal) {
+ index = j;
+ break;
+ }
+ }
+ }
+ return index;
+ },
+ /**
+ * Separate dataPoints coming from the read_group(s) into different datasets.
+ * This function returns the parameters data and labels used to produce the charts.
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ * @param {function} getLabel,
+ * @param {function} getDatasetLabel, determines to which dataset belong a given data point
+ * @param {function} [getDatasetDataLength], determines the initial section of the labels array
+ * over which the datasets have to be completed. These sections only depend
+ * on the datasets origins. Default is the constant function _ => labels.length.
+ * @returns {Object} the parameter data used to instantiate the chart.
+ */
+ _prepareData: function (dataPoints) {
+ var self = this;
+
+ var labelMap = {};
+ var labels = dataPoints.reduce(
+ function (acc, dataPt) {
+ var label = self._getLabel(dataPt);
+ var labelKey = dataPt.resId + ':' + JSON.stringify(label);
+ var index = labelMap[labelKey];
+ if (index === undefined) {
+ labelMap[labelKey] = dataPt.labelIndex = acc.length;
+ acc.push(label);
+ }
+ else{
+ dataPt.labelIndex = index;
+ }
+ return acc;
+ },
+ []
+ );
+
+ var newDataset = function (datasetLabel, originIndex) {
+ var data = new Array(self._getDatasetDataLength(originIndex, labels.length)).fill(0);
+ const domain = new Array(self._getDatasetDataLength(originIndex, labels.length)).fill([]);
+ return {
+ label: datasetLabel,
+ data: data,
+ domain: domain,
+ originIndex: originIndex,
+ };
+ };
+
+ // dataPoints --> datasets
+ var datasets = _.values(dataPoints.reduce(
+ function (acc, dataPt) {
+ var datasetLabel = self._getDatasetLabel(dataPt);
+ if (!(datasetLabel in acc)) {
+ acc[datasetLabel] = newDataset(datasetLabel, dataPt.originIndex);
+ }
+ var labelIndex = dataPt.labelIndex;
+ acc[datasetLabel].data[labelIndex] = dataPt.value;
+ acc[datasetLabel].domain[labelIndex] = dataPt.domain;
+ return acc;
+ },
+ {}
+ ));
+
+ // sort by origin
+ datasets = datasets.sort(function (dataset1, dataset2) {
+ return dataset1.originIndex - dataset2.originIndex;
+ });
+
+ return {
+ datasets: datasets,
+ labels: labels,
+ };
+ },
+ /**
+ * Prepare options for the chart according to the current mode (= chart type).
+ * This function returns the parameter options used to instantiate the chart
+ *
+ * @private
+ * @param {number} datasetsCount
+ * @returns {Object} the chart options used for the current mode
+ */
+ _prepareOptions: function (datasetsCount) {
+ const options = {
+ maintainAspectRatio: false,
+ scales: this._getScaleOptions(),
+ legend: this._getLegendOptions(datasetsCount),
+ tooltips: this._getTooltipOptions(),
+ elements: this._getElementOptions(),
+ };
+ if (this._isRedirectionEnabled()) {
+ options.onClick = this._onGraphClicked.bind(this);
+ }
+ return options;
+ },
+ /**
+ * Determine how to relabel a label according to a given origin.
+ * The idea is that the getLabel function is in general not invertible but
+ * it is when restricted to the set of dataPoints coming from a same origin.
+
+ * @private
+ * @param {Array} label
+ * @param {Array} originIndex
+ * @returns {string}
+ */
+ _relabelling: function (label, originIndex) {
+ if (label.isNoData) {
+ return label[0];
+ }
+ var i = this.state.comparisonFieldIndex;
+ if (_.contains(['bar', 'line'], this.state.mode) && i === 0) {
+ // here label is an array of length 1 and contains a number
+ return this.dateClasses.representative(label, originIndex) || '';
+ } else if (this.state.mode === 'pie' && i === 0) {
+ // here label is an array of length at least one containing string or numbers
+ var labelCopy = label.slice(0);
+ if (originIndex !== undefined) {
+ labelCopy.splice(i, 1, this.dateClasses.representative(label[i], originIndex));
+ } else {
+ labelCopy.splice(i, 1, this.dateClasses.dateClassMembers(label[i]));
+ }
+ return labelCopy.join('/');
+ }
+ // here label is an array containing strings or numbers.
+ return label.join('/') || _t('Total');
+ },
+ /**
+ * Render the chart or display a message error in case data is not good enough.
+ *
+ * Note that This method is synchronous, but the actual rendering is done
+ * asynchronously. The reason for that is that Chart.js needs to be in the
+ * DOM to correctly render itself. So, we trick Odoo by returning
+ * immediately, then we render the chart when the widget is in the DOM.
+ *
+ * @override
+ */
+ async _renderView() {
+ if (this.chart) {
+ this.chart.destroy();
+ }
+ this.$el.empty();
+ if (!_.contains(CHART_TYPES, this.state.mode)) {
+ this.trigger_up('warning', {
+ title: _t('Invalid mode for chart'),
+ message: _t('Cannot render chart with mode : ') + this.state.mode
+ });
+ }
+ var dataPoints = this._filterDataPoints();
+ dataPoints = this._sortDataPoints(dataPoints);
+ if (this.isInDOM) {
+ this._renderTitle();
+
+ // detect if some pathologies are still present after the filtering
+ if (this.state.mode === 'pie') {
+ const someNegative = dataPoints.some(dataPt => dataPt.value < 0);
+ const somePositive = dataPoints.some(dataPt => dataPt.value > 0);
+ if (someNegative && somePositive) {
+ const context = {
+ title: _t("Invalid data"),
+ description: [
+ _t("Pie chart cannot mix positive and negative numbers. "),
+ _t("Try to change your domain to only display positive results")
+ ].join("")
+ };
+ this._renderNoContentHelper(context);
+ return;
+ }
+ }
+
+ if (this.state.isSample && !this.isEmbedded) {
+ this._renderNoContentHelper();
+ }
+
+ // only render the graph if the widget is already in the DOM (this
+ // happens typically after an update), otherwise, it will be
+ // rendered when the widget will be attached to the DOM (see
+ // 'on_attach_callback')
+ var $canvasContainer = $('<div/>', {class: 'o_graph_canvas_container'});
+ var $canvas = $('<canvas/>').attr('id', this.chartId);
+ $canvasContainer.append($canvas);
+ this.$el.append($canvasContainer);
+
+ var i = this.state.comparisonFieldIndex;
+ if (i === 0) {
+ this.dateClasses = this._getDateClasses(dataPoints);
+ }
+ if (this.state.mode === 'bar') {
+ this._renderBarChart(dataPoints);
+ } else if (this.state.mode === 'line') {
+ this._renderLineChart(dataPoints);
+ } else if (this.state.mode === 'pie') {
+ this._renderPieChart(dataPoints);
+ }
+ }
+ },
+ /**
+ * create bar chart.
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ */
+ _renderBarChart: function (dataPoints) {
+ var self = this;
+
+ // prepare data
+ var data = this._prepareData(dataPoints);
+
+ data.datasets.forEach(function (dataset, index) {
+ // used when stacked
+ dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined;
+ // set dataset color
+ var color = self._getColor(index);
+ dataset.backgroundColor = color;
+ });
+
+ // prepare options
+ var options = this._prepareOptions(data.datasets.length);
+
+ // create chart
+ var ctx = document.getElementById(this.chartId);
+ this.chart = new Chart(ctx, {
+ type: 'bar',
+ data: data,
+ options: options,
+ });
+ },
+ /**
+ * create line chart.
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ */
+ _renderLineChart: function (dataPoints) {
+ var self = this;
+
+ // prepare data
+ var data = this._prepareData(dataPoints);
+ data.datasets.forEach(function (dataset, index) {
+ if (self.state.processedGroupBy.length <= 1 && self.state.origins.length > 1) {
+ if (dataset.originIndex === 0) {
+ dataset.fill = 'origin';
+ dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4);
+ dataset.borderColor = hexToRGBA(COLORS[0], 1);
+ } else if (dataset.originIndex === 1) {
+ dataset.borderColor = hexToRGBA(COLORS[1], 1);
+ } else {
+ dataset.borderColor = self._getColor(index);
+ }
+ } else {
+ dataset.borderColor = self._getColor(index);
+ }
+ if (data.labels.length === 1) {
+ // shift of the real value to right. This is done to center the points in the chart
+ // See data.labels below in Chart parameters
+ dataset.data.unshift(undefined);
+ }
+ dataset.pointBackgroundColor = dataset.borderColor;
+ dataset.pointBorderColor = 'rgba(0,0,0,0.2)';
+ });
+ if (data.datasets.length === 1) {
+ const dataset = data.datasets[0];
+ dataset.fill = 'origin';
+ dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4);
+ }
+
+ // center the points in the chart (without that code they are put on the left and the graph seems empty)
+ data.labels = data.labels.length > 1 ?
+ data.labels :
+ Array.prototype.concat.apply([], [[['']], data.labels, [['']]]);
+
+ // prepare options
+ var options = this._prepareOptions(data.datasets.length);
+
+ // create chart
+ var ctx = document.getElementById(this.chartId);
+ this.chart = new Chart(ctx, {
+ type: 'line',
+ data: data,
+ options: options,
+ });
+ },
+ /**
+ * create pie chart
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ */
+ _renderPieChart: function (dataPoints) {
+ var self = this;
+ // prepare data
+ var data = {};
+ var colors = [];
+ const allZero = dataPoints.every(dataPt => dataPt.value === 0);
+ if (allZero) {
+ // add fake data to display a pie chart with a grey zone associated
+ // with every origin
+ data.labels = [NO_DATA];
+ data.datasets = this.state.origins.map(function (origin) {
+ return {
+ label: origin,
+ data: [1],
+ backgroundColor: ['#d3d3d3'],
+ };
+ });
+ } else {
+ data = this._prepareData(dataPoints);
+ // give same color to same groups from different origins
+ colors = data.labels.map(function (label, index) {
+ return self._getColor(index);
+ });
+ data.datasets.forEach(function (dataset) {
+ dataset.backgroundColor = colors;
+ dataset.borderColor = 'rgba(255,255,255,0.6)';
+ });
+ // make sure there is a zone associated with every origin
+ var representedOriginIndexes = data.datasets.map(function (dataset) {
+ return dataset.originIndex;
+ });
+ var addNoDataToLegend = false;
+ var fakeData = (new Array(data.labels.length)).concat([1]);
+ this.state.origins.forEach(function (origin, originIndex) {
+ if (!_.contains(representedOriginIndexes, originIndex)) {
+ data.datasets.splice(originIndex, 0, {
+ label: origin,
+ data: fakeData,
+ backgroundColor: colors.concat(['#d3d3d3']),
+ });
+ addNoDataToLegend = true;
+ }
+ });
+ if (addNoDataToLegend) {
+ data.labels.push(NO_DATA);
+ }
+ }
+
+ // prepare options
+ var options = this._prepareOptions(data.datasets.length);
+
+ // create chart
+ var ctx = document.getElementById(this.chartId);
+ this.chart = new Chart(ctx, {
+ type: 'pie',
+ data: data,
+ options: options,
+ });
+ },
+ /**
+ * Add the graph title (if any) above the canvas
+ *
+ * @private
+ */
+ _renderTitle: function () {
+ if (this.title) {
+ this.$el.prepend($('<label/>', {
+ text: this.title,
+ }));
+ }
+ },
+ /**
+ * Used to avoid too long legend items
+ *
+ * @private
+ * @param {string} label
+ * @returns {string} shortened version of the input label
+ */
+ _shortenLabel: function (label) {
+ // string returned could be 'wrong' if a groupby value contain a '/'!
+ var groups = label.split("/");
+ var shortLabel = groups.slice(0, 3).join("/");
+ if (shortLabel.length > 30) {
+ shortLabel = shortLabel.slice(0, 30) + '...';
+ } else if (groups.length > 3) {
+ shortLabel = shortLabel + '/...';
+ }
+ return shortLabel;
+ },
+ /**
+ * Sort datapoints according to the current order (ASC or DESC).
+ *
+ * Note: this should be moved to the model at some point.
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ * @returns {Object[]} sorted dataPoints if orderby set on state
+ */
+ _sortDataPoints(dataPoints) {
+ if (!Object.keys(this.state.timeRanges).length && this.state.orderBy &&
+ ['bar', 'line'].includes(this.state.mode) && this.state.groupBy.length) {
+ // group data by their x-axis value, and then sort datapoints
+ // based on the sum of values by group in ascending/descending order
+ const groupByFieldName = this.state.groupBy[0].split(':')[0];
+ const groupedByMany2One = this.fields[groupByFieldName].type === 'many2one';
+ const groupedDataPoints = {};
+ dataPoints.forEach(function (dataPoint) {
+ const key = groupedByMany2One ? dataPoint.resId : dataPoint.labels[0];
+ groupedDataPoints[key] = groupedDataPoints[key] || [];
+ groupedDataPoints[key].push(dataPoint);
+ });
+ dataPoints = _.sortBy(groupedDataPoints, function (group) {
+ return group.reduce((sum, dataPoint) => sum + dataPoint.value, 0);
+ });
+ dataPoints = dataPoints.flat();
+ if (this.state.orderBy === 'desc') {
+ dataPoints = dataPoints.reverse('value');
+ }
+ }
+ return dataPoints;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onGraphClicked: function (ev) {
+ const activeElement = this.chart.getElementAtEvent(ev);
+ if (activeElement.length === 0) {
+ return;
+ }
+ const domain = this.chart.data.datasets[activeElement[0]._datasetIndex].domain;
+ if (!domain) {
+ return; // empty dataset
+ }
+ this.trigger_up('open_view', {
+ domain: domain[activeElement[0]._index],
+ });
+ },
+ /**
+ * If the text of a legend item has been shortened and the user mouse over
+ * that item (actually the event type is mousemove), a tooltip with the item
+ * full text is displayed.
+ *
+ * @private
+ * @param {MouseEvent} e
+ * @param {Object} legendItem
+ */
+ _onlegendTooltipHover: function (e, legendItem) {
+ // set cursor pointer on hover of legend
+ e.target.style.cursor = 'pointer';
+ // The string legendItem.text is an initial segment of legendItem.fullText.
+ // If the two coincide, no need to generate a tooltip.
+ // If a tooltip for the legend already exists, it is already good and don't need
+ // to be recreated.
+ if (legendItem.text === legendItem.fullText || this.$legendTooltip) {
+ return;
+ }
+
+ const chartAreaLeft = this.chart.chartArea.left;
+ const chartAreaRight = this.chart.chartArea.right;
+ const rendererTop = this.$el[0].getBoundingClientRect().top;
+
+ this.$legendTooltip = $('<div>', {
+ class: "o_tooltip_legend",
+ text: legendItem.fullText,
+ css: {
+ maxWidth: Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px',
+ top: (e.clientY - rendererTop) + 'px',
+ }
+ });
+ const $container = this.$el.find('.o_graph_canvas_container');
+ $container.append(this.$legendTooltip);
+
+ this._fixTooltipLeftPosition(this.$legendTooltip[0], e.clientX);
+ },
+ /**
+ * If there's a legend tooltip and the user mouse out of the corresponding
+ * legend item, the tooltip is removed.
+ *
+ * @private
+ */
+ _onLegendTootipLeave: function (e) {
+ // remove cursor style pointer on mouseleave from legend
+ e.target.style.cursor = "";
+ if (this.$legendTooltip) {
+ this.$legendTooltip.remove();
+ this.$legendTooltip = null;
+ }
+ },
+});
+});
diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js
new file mode 100644
index 00000000..759817c8
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_view.js
@@ -0,0 +1,162 @@
+odoo.define('web.GraphView', function (require) {
+"use strict";
+
+/**
+ * The Graph View is responsible to display a graphical (meaning: chart)
+ * representation of the current dataset. As of now, it is currently able to
+ * display data in three types of chart: bar chart, line chart and pie chart.
+ */
+
+var AbstractView = require('web.AbstractView');
+var core = require('web.core');
+var GraphModel = require('web.GraphModel');
+var Controller = require('web.GraphController');
+var GraphRenderer = require('web.GraphRenderer');
+
+var _t = core._t;
+var _lt = core._lt;
+
+var searchUtils = require('web.searchUtils');
+var GROUPABLE_TYPES = searchUtils.GROUPABLE_TYPES;
+
+var GraphView = AbstractView.extend({
+ display_name: _lt('Graph'),
+ icon: 'fa-bar-chart',
+ jsLibs: [
+ '/web/static/lib/Chart/Chart.js',
+ ],
+ config: _.extend({}, AbstractView.prototype.config, {
+ Model: GraphModel,
+ Controller: Controller,
+ Renderer: GraphRenderer,
+ }),
+ viewType: 'graph',
+ searchMenuTypes: ['filter', 'groupBy', 'comparison', 'favorite'],
+
+ /**
+ * @override
+ */
+ init: function (viewInfo, params) {
+ this._super.apply(this, arguments);
+
+ const additionalMeasures = params.additionalMeasures || [];
+ let measure;
+ const measures = {};
+ const measureStrings = {};
+ let groupBys = [];
+ const groupableFields = {};
+ this.fields.__count__ = { string: _t("Count"), type: 'integer' };
+
+ this.arch.children.forEach(field => {
+ let fieldName = field.attrs.name;
+ if (fieldName === "id") {
+ return;
+ }
+ const interval = field.attrs.interval;
+ if (interval) {
+ fieldName = fieldName + ':' + interval;
+ }
+ if (field.attrs.type === 'measure') {
+ const { string } = this.fields[fieldName];
+ measure = fieldName;
+ measures[fieldName] = {
+ description: string,
+ fieldName,
+ groupNumber: 0,
+ isActive: false,
+ itemType: 'measure',
+ };
+ } else {
+ groupBys.push(fieldName);
+ }
+ if (field.attrs.string) {
+ measureStrings[fieldName] = field.attrs.string;
+ }
+ });
+
+ for (const name in this.fields) {
+ const field = this.fields[name];
+ if (name !== 'id' && field.store === true) {
+ if (
+ ['integer', 'float', 'monetary'].includes(field.type) ||
+ additionalMeasures.includes(name)
+ ) {
+ measures[name] = {
+ description: field.string,
+ fieldName: name,
+ groupNumber: 0,
+ isActive: false,
+ itemType: 'measure',
+ };
+ }
+ if (GROUPABLE_TYPES.includes(field.type)) {
+ groupableFields[name] = field;
+ }
+ }
+ }
+ for (const name in measureStrings) {
+ if (measures[name]) {
+ measures[name].description = measureStrings[name];
+ }
+ }
+
+ // Remove invisible fields from the measures
+ this.arch.children.forEach(field => {
+ let fieldName = field.attrs.name;
+ if (field.attrs.invisible && py.eval(field.attrs.invisible)) {
+ groupBys = groupBys.filter(groupBy => groupBy !== fieldName);
+ if (fieldName in groupableFields) {
+ delete groupableFields[fieldName];
+ }
+ if (!additionalMeasures.includes(fieldName)) {
+ delete measures[fieldName];
+ }
+ }
+ });
+
+ const sortedMeasures = Object.values(measures).sort((a, b) => {
+ const descA = a.description.toLowerCase();
+ const descB = b.description.toLowerCase();
+ return descA > descB ? 1 : descA < descB ? -1 : 0;
+ });
+ const countMeasure = {
+ description: _t("Count"),
+ fieldName: '__count__',
+ groupNumber: 1,
+ isActive: false,
+ itemType: 'measure',
+ };
+ this.controllerParams.withButtons = params.withButtons !== false;
+ this.controllerParams.measures = [...sortedMeasures, countMeasure];
+ this.controllerParams.groupableFields = groupableFields;
+ this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled");
+ // retrieve form and list view ids from the action to open those views
+ // when the graph is clicked
+ function _findView(views, viewType) {
+ const view = views.find(view => {
+ return view.type === viewType;
+ });
+ return [view ? view.viewID : false, viewType];
+ }
+ this.controllerParams.views = [
+ _findView(params.actionViews, 'list'),
+ _findView(params.actionViews, 'form'),
+ ];
+
+ this.rendererParams.fields = this.fields;
+ this.rendererParams.title = this.arch.attrs.title; // TODO: use attrs.string instead
+ this.rendererParams.disableLinking = !!JSON.parse(this.arch.attrs.disable_linking || '0');
+
+ this.loadParams.mode = this.arch.attrs.type || 'bar';
+ this.loadParams.orderBy = this.arch.attrs.order;
+ this.loadParams.measure = measure || '__count__';
+ this.loadParams.groupBys = groupBys;
+ this.loadParams.fields = this.fields;
+ this.loadParams.comparisonDomain = params.comparisonDomain;
+ this.loadParams.stacked = this.arch.attrs.stacked !== "False";
+ },
+});
+
+return GraphView;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js
new file mode 100644
index 00000000..4aeb5404
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_column.js
@@ -0,0 +1,411 @@
+odoo.define('web.KanbanColumn', function (require) {
+"use strict";
+
+var config = require('web.config');
+var core = require('web.core');
+var session = require('web.session');
+var Dialog = require('web.Dialog');
+var KanbanRecord = require('web.KanbanRecord');
+var RecordQuickCreate = require('web.kanban_record_quick_create');
+var view_dialogs = require('web.view_dialogs');
+var viewUtils = require('web.viewUtils');
+var Widget = require('web.Widget');
+var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var KanbanColumn = Widget.extend({
+ template: 'KanbanView.Group',
+ custom_events: {
+ cancel_quick_create: '_onCancelQuickCreate',
+ quick_create_add_record: '_onQuickCreateAddRecord',
+ tweak_column: '_onTweakColumn',
+ tweak_column_records: '_onTweakColumnRecords',
+ },
+ events: {
+ 'click .o_column_edit': '_onEditColumn',
+ 'click .o_column_delete': '_onDeleteColumn',
+ 'click .o_kanban_quick_add': '_onAddQuickCreate',
+ 'click .o_kanban_load_more': '_onLoadMore',
+ 'click .o_kanban_toggle_fold': '_onToggleFold',
+ 'click .o_column_archive_records': '_onArchiveRecords',
+ 'click .o_column_unarchive_records': '_onUnarchiveRecords',
+ 'click .o_kanban_config .dropdown-menu': '_onConfigDropdownClicked',
+ },
+ /**
+ * @override
+ */
+ init: function (parent, data, options, recordOptions) {
+ this._super(parent);
+ this.db_id = data.id;
+ this.data_records = data.data;
+ this.data = data;
+
+ var value = data.value;
+ this.id = data.res_id;
+ this.folded = !data.isOpen;
+ this.has_active_field = 'active' in data.fields;
+ this.fields = data.fields;
+ this.records = [];
+ this.modelName = data.model;
+
+ this.quick_create = options.quick_create;
+ this.quickCreateView = options.quickCreateView;
+ this.groupedBy = options.groupedBy;
+ this.grouped_by_m2o = options.grouped_by_m2o;
+ this.editable = options.editable;
+ this.deletable = options.deletable;
+ this.archivable = options.archivable;
+ this.draggable = options.draggable;
+ this.KanbanRecord = options.KanbanRecord || KanbanRecord; // the KanbanRecord class to use
+ this.records_editable = options.records_editable;
+ this.records_deletable = options.records_deletable;
+ this.recordsDraggable = options.recordsDraggable;
+ this.relation = options.relation;
+ this.offset = 0;
+ this.remaining = data.count - this.data_records.length;
+ this.canBeFolded = this.folded;
+
+ if (options.hasProgressBar) {
+ this.barOptions = {
+ columnID: this.db_id,
+ progressBarStates: options.progressBarStates,
+ };
+ }
+
+ this.record_options = _.clone(recordOptions);
+
+ if (options.grouped_by_m2o || options.grouped_by_date ) {
+ // For many2one and datetime, a false value means that the field is not set.
+ this.title = value ? value : _t('Undefined');
+ } else {
+ // False and 0 might be valid values for these fields.
+ this.title = value === undefined ? _t('Undefined') : value;
+ }
+
+ if (options.group_by_tooltip) {
+ this.tooltipInfo = _.compact(_.map(options.group_by_tooltip, function (help, field) {
+ help = help ? help + "</br>" : '';
+ return (data.tooltipData && data.tooltipData[field] && "<div>" + help + data.tooltipData[field] + "</div>") || '';
+ }));
+ this.tooltipInfo = this.tooltipInfo.join("<div class='dropdown-divider' role='separator' />");
+ }
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ var defs = [this._super.apply(this, arguments)];
+ this.$header = this.$('.o_kanban_header');
+
+ for (var i = 0; i < this.data_records.length; i++) {
+ defs.push(this._addRecord(this.data_records[i]));
+ }
+
+ if (this.recordsDraggable) {
+ this.$el.sortable({
+ connectWith: '.o_kanban_group',
+ containment: this.draggable ? false : 'parent',
+ revert: 0,
+ delay: 0,
+ items: '> .o_kanban_record:not(.o_updating)',
+ cursor: 'move',
+ over: function () {
+ self.$el.addClass('o_kanban_hover');
+ },
+ out: function () {
+ self.$el.removeClass('o_kanban_hover');
+ },
+ start: function (event, ui) {
+ ui.item.addClass('o_currently_dragged');
+ },
+ stop: function (event, ui) {
+ var item = ui.item;
+ setTimeout(function () {
+ item.removeClass('o_currently_dragged');
+ });
+ },
+ update: function (event, ui) {
+ var record = ui.item.data('record');
+ var index = self.records.indexOf(record);
+ record.$el.removeAttr('style'); // jqueryui sortable add display:block inline
+ if (index >= 0) {
+ if ($.contains(self.$el[0], record.$el[0])) {
+ // resequencing records
+ self.trigger_up('kanban_column_resequence', {ids: self._getIDs()});
+ }
+ } else {
+ // adding record to this column
+ ui.item.addClass('o_updating');
+ self.trigger_up('kanban_column_add_record', {record: record, ids: self._getIDs()});
+ }
+ }
+ });
+ }
+ this.$el.click(function (event) {
+ if (self.folded) {
+ self._onToggleFold(event);
+ }
+ });
+ if (this.barOptions) {
+ this.$el.addClass('o_kanban_has_progressbar');
+ this.progressBar = new KanbanColumnProgressBar(this, this.barOptions, this.data);
+ defs.push(this.progressBar.appendTo(this.$header));
+ }
+
+ var title = this.folded ? this.title + ' (' + this.data.count + ')' : this.title;
+ this.$header.find('.o_column_title').text(title);
+
+ this.$el.toggleClass('o_column_folded', this.canBeFolded);
+ if (this.tooltipInfo) {
+ this.$header.find('.o_kanban_header_title').tooltip({}).attr('data-original-title', this.tooltipInfo);
+ }
+ if (!this.remaining) {
+ this.$('.o_kanban_load_more').remove();
+ } else {
+ this.$('.o_kanban_load_more').html(QWeb.render('KanbanView.LoadMore', {widget: this}));
+ }
+
+ return Promise.all(defs);
+ },
+ /**
+ * Called when a record has been quick created, as a new column is rendered
+ * and appended into a fragment, before replacing the old column in the DOM.
+ * When this happens, the quick create widget is inserted into the new
+ * column directly, and it should be focused. However, as it is rendered
+ * into a fragment, the focus has to be set manually once in the DOM.
+ */
+ on_attach_callback: function () {
+ _.invoke(this.records, 'on_attach_callback');
+ if (this.quickCreateWidget) {
+ this.quickCreateWidget.on_attach_callback();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds the quick create record to the top of the column.
+ *
+ * @returns {Promise}
+ */
+ addQuickCreate: async function () {
+ if (this.folded) {
+ // first open the column, and then add the quick create
+ this.trigger_up('column_toggle_fold', {
+ openQuickCreate: true,
+ });
+ return;
+ }
+
+ if (this.quickCreateWidget) {
+ return Promise.reject();
+ }
+ this.trigger_up('close_quick_create'); // close other quick create widgets
+ var context = this.data.getContext();
+ context['default_' + this.groupedBy] = viewUtils.getGroupValue(this.data, this.groupedBy);
+ this.quickCreateWidget = new RecordQuickCreate(this, {
+ context: context,
+ formViewRef: this.quickCreateView,
+ model: this.modelName,
+ });
+ await this.quickCreateWidget.appendTo(document.createDocumentFragment());
+ this.trigger_up('start_quick_create');
+ this.quickCreateWidget.$el.insertAfter(this.$header);
+ this.quickCreateWidget.on_attach_callback();
+ },
+ /**
+ * Closes the quick create widget if it isn't dirty.
+ */
+ cancelQuickCreate: function () {
+ if (this.quickCreateWidget) {
+ this.quickCreateWidget.cancel();
+ }
+ },
+ /**
+ * @returns {Boolean} true iff the column is empty
+ */
+ isEmpty: function () {
+ return !this.records.length;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds a record in the column.
+ *
+ * @private
+ * @param {Object} recordState
+ * @param {Object} [options]
+ * @param {string} [options.position]
+ * 'before' to add at the top, add at the bottom by default
+ * @return {Promise}
+ */
+ _addRecord: function (recordState, options) {
+ var record = new this.KanbanRecord(this, recordState, this.record_options);
+ this.records.push(record);
+ if (options && options.position === 'before') {
+ return record.insertAfter(this.quickCreateWidget ? this.quickCreateWidget.$el : this.$header);
+ } else {
+ var $load_more = this.$('.o_kanban_load_more');
+ if ($load_more.length) {
+ return record.insertBefore($load_more);
+ } else {
+ return record.appendTo(this.$el);
+ }
+ }
+ },
+ /**
+ * Destroys the QuickCreate widget.
+ *
+ * @private
+ */
+ _cancelQuickCreate: function () {
+ this.quickCreateWidget.destroy();
+ this.quickCreateWidget = undefined;
+ },
+ /**
+ * @returns {integer[]} the res_ids of the records in the column
+ */
+ _getIDs: function () {
+ var ids = [];
+ this.$('.o_kanban_record').each(function (index, r) {
+ ids.push($(r).data('record').id);
+ });
+ return ids;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAddQuickCreate: function () {
+ this.trigger_up('add_quick_create', { groupId: this.db_id });
+ },
+ /**
+ * @private
+ */
+ _onCancelQuickCreate: function () {
+ this._cancelQuickCreate();
+ },
+ /**
+ * Prevent from closing the config dropdown when the user clicks on a
+ * disabled item (e.g. 'Fold' in sample mode).
+ *
+ * @private
+ */
+ _onConfigDropdownClicked(ev) {
+ ev.stopPropagation();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onDeleteColumn: function (event) {
+ event.preventDefault();
+ var buttons = [
+ {
+ text: _t("Ok"),
+ classes: 'btn-primary',
+ close: true,
+ click: this.trigger_up.bind(this, 'kanban_column_delete'),
+ },
+ {text: _t("Cancel"), close: true}
+ ];
+ new Dialog(this, {
+ size: 'medium',
+ buttons: buttons,
+ $content: $('<div>', {
+ text: _t("Are you sure that you want to remove this column ?")
+ }),
+ }).open();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onEditColumn: function (event) {
+ event.preventDefault();
+ new view_dialogs.FormViewDialog(this, {
+ res_model: this.relation,
+ res_id: this.id,
+ context: session.user_context,
+ title: _t("Edit Column"),
+ on_saved: this.trigger_up.bind(this, 'reload'),
+ }).open();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onLoadMore: function (event) {
+ event.preventDefault();
+ this.trigger_up('kanban_load_more');
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onQuickCreateAddRecord: function (event) {
+ this.trigger_up('quick_create_record', event.data);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onToggleFold: function (event) {
+ event.preventDefault();
+ this.trigger_up('column_toggle_fold');
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onTweakColumn: function (ev) {
+ ev.data.callback(this.$el);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onTweakColumnRecords: function (ev) {
+ _.each(this.records, function (record) {
+ ev.data.callback(record.$el, record.state.data);
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onArchiveRecords: function (event) {
+ event.preventDefault();
+ Dialog.confirm(this, _t("Are you sure that you want to archive all the records from this column?"), {
+ confirm_callback: this.trigger_up.bind(this, 'kanban_column_records_toggle_active', {
+ archive: true,
+ }),
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onUnarchiveRecords: function (event) {
+ event.preventDefault();
+ this.trigger_up('kanban_column_records_toggle_active', {
+ archive: false,
+ });
+ }
+});
+
+return KanbanColumn;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js
new file mode 100644
index 00000000..752d2b2d
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js
@@ -0,0 +1,288 @@
+odoo.define('web.KanbanColumnProgressBar', function (require) {
+'use strict';
+
+const core = require('web.core');
+var session = require('web.session');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+
+const _t = core._t;
+
+var KanbanColumnProgressBar = Widget.extend({
+ template: 'KanbanView.ColumnProgressBar',
+ events: {
+ 'click .o_kanban_counter_progress': '_onProgressBarParentClick',
+ 'click .progress-bar': '_onProgressBarClick',
+ },
+ /**
+ * Allows to disable animations for tests.
+ * @type {boolean}
+ */
+ ANIMATE: true,
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options, columnState) {
+ this._super.apply(this, arguments);
+
+ this.columnID = options.columnID;
+ this.columnState = columnState;
+
+ // <progressbar/> attributes
+ this.fieldName = columnState.progressBarValues.field;
+ this.colors = _.extend({}, columnState.progressBarValues.colors, {
+ __false: 'muted', // color to use for false value
+ });
+ this.sumField = columnState.progressBarValues.sum_field;
+
+ // Previous progressBar state
+ var state = options.progressBarStates[this.columnID];
+ if (state) {
+ this.groupCount = state.groupCount;
+ this.subgroupCounts = state.subgroupCounts;
+ this.totalCounterValue = state.totalCounterValue;
+ this.activeFilter = state.activeFilter;
+ }
+
+ // Prepare currency (TODO this should be automatic... use a field ?)
+ var sumFieldInfo = this.sumField && columnState.fieldsInfo.kanban[this.sumField];
+ var currencyField = sumFieldInfo && sumFieldInfo.options && sumFieldInfo.options.currency_field;
+ if (currencyField && columnState.data.length) {
+ this.currency = session.currencies[columnState.data[0].data[currencyField].res_id];
+ }
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+
+ this.$bars = {};
+ _.each(this.colors, function (val, key) {
+ self.$bars[key] = self.$(`.progress-bar[data-filter=${key}]`);
+ });
+ this.$counter = this.$('.o_kanban_counter_side');
+ this.$number = this.$counter.find('b');
+
+ if (this.currency) {
+ var $currency = $('<span/>', {
+ text: this.currency.symbol,
+ });
+ if (this.currency.position === 'before') {
+ $currency.prependTo(this.$counter);
+ } else {
+ $currency.appendTo(this.$counter);
+ }
+ }
+
+ return this._super.apply(this, arguments).then(function () {
+ // This should be executed when the progressbar is fully rendered
+ // and is in the DOM, this happens to be always the case with
+ // current use of progressbars
+ self.computeCounters();
+ self._notifyState();
+ self._render();
+ });
+ },
+ /**
+ * Computes the count of each sub group and the total count
+ */
+ computeCounters() {
+ const subgroupCounts = {};
+ let allSubgroupCount = 0;
+ for (const key of Object.keys(this.colors)) {
+ const subgroupCount = this.columnState.progressBarValues.counts[key] || 0;
+ if (this.activeFilter === key && subgroupCount === 0) {
+ this.activeFilter = false;
+ }
+ subgroupCounts[key] = subgroupCount;
+ allSubgroupCount += subgroupCount;
+ };
+ subgroupCounts.__false = this.columnState.count - allSubgroupCount;
+
+ this.groupCount = this.columnState.count;
+ this.subgroupCounts = subgroupCounts;
+ this.prevTotalCounterValue = this.totalCounterValue;
+ this.totalCounterValue = this.sumField ? (this.columnState.aggregateValues[this.sumField] || 0) : this.columnState.count;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates the rendering according to internal data. This is done without
+ * qweb rendering because there are animations.
+ *
+ * @private
+ */
+ _render: function () {
+ var self = this;
+
+ // Update column display according to active filter
+ this.trigger_up('tweak_column', {
+ callback: function ($el) {
+ $el.removeClass('o_kanban_group_show');
+ _.each(self.colors, function (val, key) {
+ $el.removeClass('o_kanban_group_show_' + val);
+ });
+ if (self.activeFilter) {
+ $el.addClass('o_kanban_group_show o_kanban_group_show_' + self.colors[self.activeFilter]);
+ }
+ },
+ });
+ this.trigger_up('tweak_column_records', {
+ callback: function ($el, recordData) {
+ var categoryValue = recordData[self.fieldName] ? recordData[self.fieldName] : '__false';
+ _.each(self.colors, function (val, key) {
+ $el.removeClass('oe_kanban_card_' + val);
+ });
+ if (self.colors[categoryValue]) {
+ $el.addClass('oe_kanban_card_' + self.colors[categoryValue]);
+ }
+ },
+ });
+
+ // Display and animate the progress bars
+ var barNumber = 0;
+ var barMinWidth = 6; // In %
+ const selection = self.columnState.fields[self.fieldName].selection;
+ _.each(self.colors, function (val, key) {
+ var $bar = self.$bars[key];
+ var count = self.subgroupCounts && self.subgroupCounts[key] || 0;
+
+ if (!$bar) {
+ return;
+ }
+
+ // Adapt tooltip
+ let value;
+ if (selection) { // progressbar on a field of type selection
+ const option = selection.find(option => option[0] === key);
+ value = option && option[1] || _t('Other');
+ } else {
+ value = key;
+ }
+ $bar.attr('data-original-title', count + ' ' + value);
+ $bar.tooltip({
+ delay: 0,
+ trigger: 'hover',
+ });
+
+ // Adapt active state
+ $bar.toggleClass('progress-bar-animated progress-bar-striped', key === self.activeFilter);
+
+ // Adapt width
+ $bar.removeClass('o_bar_has_records transition-off');
+ window.getComputedStyle($bar[0]).getPropertyValue('width'); // Force reflow so that animations work
+ if (count > 0) {
+ $bar.addClass('o_bar_has_records');
+ // Make sure every bar that has records has some space
+ // and that everything adds up to 100%
+ var maxWidth = 100 - barMinWidth * barNumber;
+ self.$('.progress-bar.o_bar_has_records').css('max-width', maxWidth + '%');
+ $bar.css('width', (count * 100 / self.groupCount) + '%');
+ barNumber++;
+ $bar.attr('aria-valuemin', 0);
+ $bar.attr('aria-valuemax', self.groupCount);
+ $bar.attr('aria-valuenow', count);
+ } else {
+ $bar.css('width', '');
+ }
+ });
+ this.$('.progress-bar').css('min-width', '');
+ this.$('.progress-bar.o_bar_has_records').css('min-width', barMinWidth + '%');
+
+ // Display and animate the counter number
+ var start = this.prevTotalCounterValue;
+ var end = this.totalCounterValue;
+
+ if (this.activeFilter) {
+ if (this.sumField) {
+ end = 0;
+ _.each(self.columnState.data, function (record) {
+ var recordData = record.data;
+ if (self.activeFilter === recordData[self.fieldName] ||
+ (self.activeFilter === '__false' && !recordData[self.fieldName])) {
+ end += parseFloat(recordData[self.sumField]);
+ }
+ });
+ } else {
+ end = this.subgroupCounts[this.activeFilter];
+ }
+ }
+ this.prevTotalCounterValue = end;
+ var animationClass = start > 999 ? 'o_kanban_grow' : 'o_kanban_grow_huge';
+
+ if (start !== undefined && (end > start || this.activeFilter) && this.ANIMATE) {
+ $({currentValue: start}).animate({currentValue: end}, {
+ duration: 1000,
+ start: function () {
+ self.$counter.addClass(animationClass);
+ },
+ step: function () {
+ self.$number.html(_getCounterHTML(this.currentValue));
+ },
+ complete: function () {
+ self.$number.html(_getCounterHTML(this.currentValue));
+ self.$counter.removeClass(animationClass);
+ },
+ });
+ } else {
+ this.$number.html(_getCounterHTML(end));
+ }
+
+ function _getCounterHTML(value) {
+ return utils.human_number(value, 0, 3);
+ }
+ },
+ /**
+ * Notifies the new progressBar state so that if a full rerender occurs, the
+ * new progressBar that would replace this one will be initialized with
+ * current state, so that animations are correct.
+ *
+ * @private
+ */
+ _notifyState: function () {
+ this.trigger_up('set_progress_bar_state', {
+ columnID: this.columnID,
+ values: {
+ groupCount: this.groupCount,
+ subgroupCounts: this.subgroupCounts,
+ totalCounterValue: this.totalCounterValue,
+ activeFilter: this.activeFilter,
+ },
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onProgressBarClick: function (ev) {
+ this.$clickedBar = $(ev.currentTarget);
+ var filter = this.$clickedBar.data('filter');
+ this.activeFilter = (this.activeFilter === filter ? false : filter);
+ this._notifyState();
+ this._render();
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onProgressBarParentClick: function (ev) {
+ if (ev.target !== ev.currentTarget) {
+ return;
+ }
+ this.activeFilter = false;
+ this._notifyState();
+ this._render();
+ },
+});
+return KanbanColumnProgressBar;
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js
new file mode 100644
index 00000000..c4bed5fa
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js
@@ -0,0 +1,246 @@
+odoo.define('web.kanban_column_quick_create', function (require) {
+"use strict";
+
+/**
+ * This file defines the ColumnQuickCreate widget for Kanban. It allows to
+ * create kanban columns directly from the Kanban view.
+ */
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var ColumnQuickCreate = Widget.extend({
+ template: 'KanbanView.ColumnQuickCreate',
+ events: {
+ 'click .o_quick_create_folded': '_onUnfold',
+ 'click .o_kanban_add': '_onAddClicked',
+ 'click .o_kanban_examples': '_onShowExamples',
+ 'keydown': '_onKeydown',
+ 'keypress input': '_onKeypress',
+ 'blur input': '_onInputBlur',
+ 'focus input': '_onInputFocus',
+ },
+
+ /**
+ * @override
+ * @param {Object} [options]
+ * @param {Object} [options.examples]
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.applyExamplesText = options.applyExampleText || _t("Use This For My Kanban");
+ this.examples = options.examples;
+ this.folded = true;
+ this.isMobile = false;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$quickCreateFolded = this.$('.o_quick_create_folded');
+ this.$quickCreateUnfolded = this.$('.o_quick_create_unfolded');
+ this.$input = this.$('input');
+
+ // destroy the quick create when the user clicks outside
+ core.bus.on('click', this, this._onWindowClicked);
+
+ this._update();
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Called each time the quick create is attached into the DOM
+ */
+ on_attach_callback: function () {
+ if (!this.folded) {
+ this.$input.focus();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Folds/unfolds the Column quick create widget
+ */
+ toggleFold: function () {
+ this.folded = !this.folded;
+ this._update();
+ if (!this.folded) {
+ this.$input.focus();
+ this.trigger_up('scrollTo', {selector: '.o_column_quick_create'});
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Clears the input value and notify the environment to create a column
+ *
+ * @private
+ */
+ _add: function () {
+ var value = this.$input.val().trim();
+ if (!value.length) {
+ this._cancel();
+ return;
+ }
+ this.$input.val('');
+ this.trigger_up('quick_create_add_column', {value: value});
+ this.$input.focus();
+ },
+ /**
+ * Cancels the quick creation
+ *
+ * @private
+ */
+ _cancel: function () {
+ if (!this.folded) {
+ this.$input.val('');
+ this.folded = true;
+ this._update();
+ }
+ },
+ /**
+ * Updates the rendering according to the current state (folded/unfolded)
+ *
+ * @private
+ */
+ _update: function () {
+ this.$quickCreateFolded.toggle(this.folded);
+ this.$quickCreateUnfolded.toggle(!this.folded);
+ this.trigger_up('quick_create_column_updated');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onAddClicked: function (event) {
+ event.stopPropagation();
+ this._add();
+ },
+ /**
+ * When the input is not focused, no key event may occur in the column, so
+ * the discard feature will not work by pressing ESC. We simply hide the
+ * help message in that case, so we do not mislead our users.
+ *
+ * @private
+ * @param {KeyboardEvent} event
+ */
+ _onInputBlur: function () {
+ this.$('.o_discard_msg').hide();
+ },
+ /**
+ * When the input is focused, we need to show the discard help message (it
+ * might have been hidden, @see _onInputBlur)
+ *
+ * @private
+ * @param {KeyboardEvent} event
+ */
+ _onInputFocus: function () {
+ this.$('.o_discard_msg').show();
+ },
+ /**
+ * Cancels quick creation on escape keydown event
+ *
+ * @private
+ * @param {KeyEvent} event
+ */
+ _onKeydown: function (event) {
+ if (event.keyCode === $.ui.keyCode.ESCAPE) {
+ this._cancel();
+ }
+ },
+ /**
+ * Validates quick creation on enter keypress event
+ *
+ * @private
+ * @param {KeyEvent} event
+ */
+ _onKeypress: function (event) {
+ if (event.keyCode === $.ui.keyCode.ENTER) {
+ this._add();
+ }
+ },
+ /**
+ * Opens a dialog containing examples of Kanban processes
+ *
+ * @private
+ */
+ _onShowExamples: function () {
+ var self = this;
+ var dialog = new Dialog(this, {
+ $content: $(QWeb.render('KanbanView.ExamplesDialog', {
+ examples: this.examples,
+ })),
+ buttons: [{
+ classes: 'btn-primary float-right',
+ text: this.applyExamplesText,
+ close: true,
+ click: function () {
+ const activeExample = self.examples[this.$('.nav-link.active').data("exampleIndex")];
+ activeExample.columns.forEach(column => {
+ self.trigger_up('quick_create_add_column', { value: column.toString(), foldQuickCreate: true });
+ });
+ }
+ }, {
+ classes: 'btn-secondary float-right',
+ close: true,
+ text: _t('Close'),
+ }],
+ size: "large",
+ title: "Kanban Examples",
+ }).open();
+ dialog.on('closed', this, function () {
+ self.$input.focus();
+ });
+ },
+ /**
+ * @private
+ */
+ _onUnfold: function () {
+ if (this.folded) {
+ this.toggleFold();
+ }
+ },
+ /**
+ * When a click happens outside the quick create, we want to close it.
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onWindowClicked: function (event) {
+ // ignore clicks if the quick create is not in the dom
+ if (!document.contains(this.el)) {
+ return;
+ }
+
+ // ignore clicks in modals
+ if ($(event.target).closest('.modal').length) {
+ return;
+ }
+
+ // ignore clicks if target is inside the quick create
+ if (this.el.contains(event.target)) {
+ return;
+ }
+
+ this._cancel();
+ },
+});
+
+return ColumnQuickCreate;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js
new file mode 100644
index 00000000..1b6e6301
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -0,0 +1,537 @@
+odoo.define('web.KanbanController', function (require) {
+"use strict";
+
+/**
+ * The KanbanController is the class that coordinates the kanban model and the
+ * kanban renderer. It also makes sure that update from the search view are
+ * properly interpreted.
+ */
+
+var BasicController = require('web.BasicController');
+var Context = require('web.Context');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var Domain = require('web.Domain');
+var view_dialogs = require('web.view_dialogs');
+var viewUtils = require('web.viewUtils');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var KanbanController = BasicController.extend({
+ buttons_template: 'KanbanView.buttons',
+ custom_events: _.extend({}, BasicController.prototype.custom_events, {
+ add_quick_create: '_onAddQuickCreate',
+ quick_create_add_column: '_onAddColumn',
+ quick_create_record: '_onQuickCreateRecord',
+ resequence_columns: '_onResequenceColumn',
+ button_clicked: '_onButtonClicked',
+ kanban_record_delete: '_onRecordDelete',
+ kanban_record_update: '_onUpdateRecord',
+ kanban_column_delete: '_onDeleteColumn',
+ kanban_column_add_record: '_onAddRecordToColumn',
+ kanban_column_resequence: '_onColumnResequence',
+ kanban_load_more: '_onLoadMore',
+ column_toggle_fold: '_onToggleColumn',
+ kanban_column_records_toggle_active: '_onToggleActiveRecords',
+ }),
+ /**
+ * @override
+ * @param {Object} params
+ * @param {boolean} params.quickCreateEnabled set to false to disable the
+ * quick create feature
+ * @param {SearchPanel} [params.searchPanel]
+ * @param {Array[]} [params.controlPanelDomain=[]] initial domain coming
+ * from the controlPanel
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.on_create = params.on_create;
+ this.hasButtons = params.hasButtons;
+ this.quickCreateEnabled = params.quickCreateEnabled;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {jQuery} [$node]
+ */
+ renderButtons: function ($node) {
+ if (!this.hasButtons || !this.is_action_enabled('create')) {
+ return;
+ }
+ this.$buttons = $(qweb.render(this.buttons_template, {
+ btnClass: 'btn-primary',
+ widget: this,
+ }));
+ this.$buttons.on('click', 'button.o-kanban-button-new', this._onButtonNew.bind(this));
+ this.$buttons.on('keydown', this._onButtonsKeyDown.bind(this));
+ if ($node) {
+ this.$buttons.appendTo($node);
+ }
+ },
+ /**
+ * In grouped mode, set 'Create' button as btn-secondary if there is no column
+ * (except if we can't create new columns)
+ *
+ * @override
+ */
+ updateButtons: function () {
+ if (!this.$buttons) {
+ return;
+ }
+ var state = this.model.get(this.handle, {raw: true});
+ var createHidden = this.is_action_enabled('group_create') && state.isGroupedByM2ONoColumn;
+ this.$buttons.find('.o-kanban-button-new').toggleClass('o_hidden', createHidden);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Displays the record quick create widget in the requested column, given its
+ * id (in the first column by default). Ensures that we removed sample data
+ * if any, before displaying the quick create.
+ *
+ * @private
+ * @param {string} [groupId]
+ */
+ _addQuickCreate(groupId) {
+ this._removeSampleData(async () => {
+ await this.update({ shouldUpdateSearchComponents: false }, { reload: false });
+ return this.renderer.addQuickCreate(groupId);
+ });
+ },
+ /**
+ * @override method comes from field manager mixin
+ * @private
+ * @param {string} id local id from the basic record data
+ * @returns {Promise}
+ */
+ _confirmSave: function (id) {
+ var data = this.model.get(this.handle, {raw: true});
+ var grouped = data.groupedBy.length;
+ if (grouped) {
+ var columnState = this.model.getColumn(id);
+ return this.renderer.updateColumn(columnState.id, columnState);
+ }
+ return this.renderer.updateRecord(this.model.get(id));
+ },
+ /**
+ * Only display the pager in the ungrouped case, with data.
+ *
+ * @override
+ * @private
+ */
+ _getPagingInfo: function (state) {
+ if (!(state.count && !state.groupedBy.length)) {
+ return null;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ * @param {Widget} kanbanRecord
+ * @param {Object} params
+ */
+ _reloadAfterButtonClick: function (kanbanRecord, params) {
+ var self = this;
+ var recordModel = this.model.localData[params.record.id];
+ var group = this.model.localData[recordModel.parentID];
+ var parent = this.model.localData[group.parentID];
+
+ this.model.reload(params.record.id).then(function (db_id) {
+ var data = self.model.get(db_id);
+ kanbanRecord.update(data);
+
+ // Check if we still need to display the record. Some fields of the domain are
+ // not guaranteed to be in data. This is for example the case if the action
+ // contains a domain on a field which is not in the Kanban view. Therefore,
+ // we need to handle multiple cases based on 3 variables:
+ // domInData: all domain fields are in the data
+ // activeInDomain: 'active' is already in the domain
+ // activeInData: 'active' is available in the data
+
+ var domain = (parent ? parent.domain : group.domain) || [];
+ var domInData = _.every(domain, function (d) {
+ return d[0] in data.data;
+ });
+ var activeInDomain = _.pluck(domain, 0).indexOf('active') !== -1;
+ var activeInData = 'active' in data.data;
+
+ // Case # | domInData | activeInDomain | activeInData
+ // 1 | true | true | true => no domain change
+ // 2 | true | true | false => not possible
+ // 3 | true | false | true => add active in domain
+ // 4 | true | false | false => no domain change
+ // 5 | false | true | true => no evaluation
+ // 6 | false | true | false => no evaluation
+ // 7 | false | false | true => replace domain
+ // 8 | false | false | false => no evaluation
+
+ // There are 3 cases which cannot be evaluated since we don't have all the
+ // necessary information. The complete solution would be to perform a RPC in
+ // these cases, but this is out of scope. A simpler one is to do a try / catch.
+
+ if (domInData && !activeInDomain && activeInData) {
+ domain = domain.concat([['active', '=', true]]);
+ } else if (!domInData && !activeInDomain && activeInData) {
+ domain = [['active', '=', true]];
+ }
+ try {
+ var visible = new Domain(domain).compute(data.evalContext);
+ } catch (e) {
+ return;
+ }
+ if (!visible) {
+ kanbanRecord.destroy();
+ }
+ });
+ },
+ /**
+ * @param {number[]} ids
+ * @private
+ * @returns {Promise}
+ */
+ _resequenceColumns: function (ids) {
+ var state = this.model.get(this.handle, {raw: true});
+ var model = state.fields[state.groupedBy[0]].relation;
+ return this.model.resequence(model, ids, this.handle);
+ },
+ /**
+ * This method calls the server to ask for a resequence. Note that this
+ * does not rerender the user interface, because in most case, the
+ * resequencing operation has already been displayed by the renderer.
+ *
+ * @private
+ * @param {string} column_id
+ * @param {string[]} ids
+ * @returns {Promise}
+ */
+ _resequenceRecords: function (column_id, ids) {
+ var self = this;
+ return this.model.resequence(this.modelName, ids, column_id);
+ },
+ /**
+ * @override
+ */
+ _shouldBounceOnClick(element) {
+ const state = this.model.get(this.handle, {raw: true});
+ if (!state.count || state.isSample) {
+ const classesList = [
+ 'o_kanban_view',
+ 'o_kanban_group',
+ 'o_kanban_header',
+ 'o_column_quick_create',
+ 'o_view_nocontent_smiling_face',
+ ];
+ return classesList.some(c => element.classList.contains(c));
+ }
+ return false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * This handler is called when an event (from the quick create add column)
+ * event bubbles up. When that happens, we need to ask the model to create
+ * a group and to update the renderer
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onAddColumn: function (ev) {
+ var self = this;
+ this.mutex.exec(function () {
+ return self.model.createGroup(ev.data.value, self.handle).then(function () {
+ var state = self.model.get(self.handle, {raw: true});
+ var ids = _.pluck(state.data, 'res_id').filter(_.isNumber);
+ return self._resequenceColumns(ids);
+ }).then(function () {
+ return self.update({}, {reload: false});
+ }).then(function () {
+ let quickCreateFolded = self.renderer.quickCreate.folded;
+ if (ev.data.foldQuickCreate ? !quickCreateFolded : quickCreateFolded) {
+ self.renderer.quickCreateToggleFold();
+ }
+ self.renderer.trigger_up("quick_create_column_created");
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onAddRecordToColumn: function (ev) {
+ var self = this;
+ var record = ev.data.record;
+ var column = ev.target;
+ this.alive(this.model.moveRecord(record.db_id, column.db_id, this.handle))
+ .then(function (column_db_ids) {
+ return self._resequenceRecords(column.db_id, ev.data.ids)
+ .then(function () {
+ _.each(column_db_ids, function (db_id) {
+ var data = self.model.get(db_id);
+ self.renderer.updateColumn(db_id, data);
+ });
+ });
+ }).guardedCatch(this.reload.bind(this));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @returns {string} ev.data.groupId
+ */
+ _onAddQuickCreate(ev) {
+ ev.stopPropagation();
+ this._addQuickCreate(ev.data.groupId);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onButtonClicked: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ var attrs = ev.data.attrs;
+ var record = ev.data.record;
+ var def = Promise.resolve();
+ if (attrs.context) {
+ attrs.context = new Context(attrs.context)
+ .set_eval_context({
+ active_id: record.res_id,
+ active_ids: [record.res_id],
+ active_model: record.model,
+ });
+ }
+ if (attrs.confirm) {
+ def = new Promise(function (resolve, reject) {
+ Dialog.confirm(this, attrs.confirm, {
+ confirm_callback: resolve,
+ cancel_callback: reject,
+ }).on("closed", null, reject);
+ });
+ }
+ def.then(function () {
+ self.trigger_up('execute_action', {
+ action_data: attrs,
+ env: {
+ context: record.getContext(),
+ currentID: record.res_id,
+ model: record.model,
+ resIDs: record.res_ids,
+ },
+ on_closed: self._reloadAfterButtonClick.bind(self, ev.target, ev.data),
+ });
+ });
+ },
+ /**
+ * @private
+ */
+ _onButtonNew: function () {
+ var state = this.model.get(this.handle, {raw: true});
+ var quickCreateEnabled = this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(state);
+ if (this.on_create === 'quick_create' && quickCreateEnabled && state.data.length) {
+ // activate the quick create in the first column when the mutex is
+ // unlocked, to ensure that there is no pending re-rendering that
+ // would remove it (e.g. if we are currently adding a new column)
+ this.mutex.getUnlockedDef().then(this._addQuickCreate.bind(this, null));
+ } else if (this.on_create && this.on_create !== 'quick_create') {
+ // Execute the given action
+ this.do_action(this.on_create, {
+ on_close: this.reload.bind(this, {}),
+ additional_context: state.context,
+ });
+ } else {
+ // Open the form view
+ this.trigger_up('switch_view', {
+ view_type: 'form',
+ res_id: undefined
+ });
+ }
+ },
+ /**
+ * Moves the focus from the controller buttons to the first kanban record
+ *
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onButtonsKeyDown: function (ev) {
+ switch(ev.keyCode) {
+ case $.ui.keyCode.DOWN:
+ this._giveFocus();
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onColumnResequence: function (ev) {
+ this._resequenceRecords(ev.target.db_id, ev.data.ids);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDeleteColumn: function (ev) {
+ var column = ev.target;
+ var state = this.model.get(this.handle, {raw: true});
+ var relatedModelName = state.fields[state.groupedBy[0]].relation;
+ this.model
+ .deleteRecords([column.db_id], relatedModelName)
+ .then(this.update.bind(this, {}, {}));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onLoadMore: function (ev) {
+ var self = this;
+ var column = ev.target;
+ this.model.loadMore(column.db_id).then(function (db_id) {
+ var data = self.model.get(db_id);
+ self.renderer.updateColumn(db_id, data);
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {KanbanColumn} ev.target the column in which the record should
+ * be added
+ * @param {Object} ev.data.values the field values of the record to
+ * create; if values only contains the value of the 'display_name', a
+ * 'name_create' is performed instead of 'create'
+ * @param {function} [ev.data.onFailure] called when the quick creation
+ * failed
+ */
+ _onQuickCreateRecord: function (ev) {
+ var self = this;
+ var values = ev.data.values;
+ var column = ev.target;
+ var onFailure = ev.data.onFailure || function () {};
+
+ // function that updates the kanban view once the record has been added
+ // it receives the local id of the created record in arguments
+ var update = function (db_id) {
+
+ var columnState = self.model.getColumn(db_id);
+ var state = self.model.get(self.handle);
+ return self.renderer
+ .updateColumn(columnState.id, columnState, {openQuickCreate: true, state: state})
+ .then(function () {
+ if (ev.data.openRecord) {
+ self.trigger_up('open_record', {id: db_id, mode: 'edit'});
+ }
+ });
+ };
+
+ this.model.createRecordInGroup(column.db_id, values)
+ .then(update)
+ .guardedCatch(function (reason) {
+ reason.event.preventDefault();
+ var columnState = self.model.get(column.db_id, {raw: true});
+ var context = columnState.getContext();
+ var state = self.model.get(self.handle, {raw: true});
+ var groupedBy = state.groupedBy[0];
+ context['default_' + groupedBy] = viewUtils.getGroupValue(columnState, groupedBy);
+ new view_dialogs.FormViewDialog(self, {
+ res_model: state.model,
+ context: _.extend({default_name: values.name || values.display_name}, context),
+ title: _t("Create"),
+ disable_multiple_selection: true,
+ on_saved: function (record) {
+ self.model.addRecordToGroup(column.db_id, record.res_id)
+ .then(update);
+ },
+ }).open().opened(onFailure);
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onRecordDelete: function (ev) {
+ this._deleteRecords([ev.data.id]);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onResequenceColumn: function (ev) {
+ var self = this;
+ this._resequenceColumns(ev.data.ids);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {boolean} [ev.data.openQuickCreate=false] if true, opens the
+ * QuickCreate in the toggled column (it assumes that we are opening it)
+ */
+ _onToggleColumn: function (ev) {
+ var self = this;
+ const columnID = ev.target.db_id || ev.data.db_id;
+ this.model.toggleGroup(columnID)
+ .then(function (db_id) {
+ var data = self.model.get(db_id);
+ var options = {
+ openQuickCreate: !!ev.data.openQuickCreate,
+ };
+ return self.renderer.updateColumn(db_id, data, options);
+ })
+ .then(function () {
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
+ }
+ });
+ },
+ /**
+ * @todo should simply use field_changed event...
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {function} [ev.data.onSuccess] callback to execute after applying
+ * changes
+ */
+ _onUpdateRecord: function (ev) {
+ var onSuccess = ev.data.onSuccess;
+ delete ev.data.onSuccess;
+ var changes = _.clone(ev.data);
+ ev.data.force_save = true;
+ this._applyChanges(ev.target.db_id, changes, ev).then(onSuccess);
+ },
+ /**
+ * Allow the user to archive/restore all the records of a column.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onToggleActiveRecords: function (ev) {
+ var self = this;
+ var archive = ev.data.archive;
+ var column = ev.target;
+ var recordIds = _.pluck(column.records, 'id');
+ if (recordIds.length) {
+ var prom = archive ?
+ this.model.actionArchive(recordIds, column.db_id) :
+ this.model.actionUnarchive(recordIds, column.db_id);
+ prom.then(function (dbID) {
+ var data = self.model.get(dbID);
+ if (data) { // Could be null if a wizard is returned for example
+ self.model.reload(self.handle).then(function () {
+ const state = self.model.get(self.handle);
+ self.renderer.updateColumn(dbID, data, { state });
+ });
+ }
+ });
+ }
+ },
+});
+
+return KanbanController;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_examples_registry.js b/addons/web/static/src/js/views/kanban/kanban_examples_registry.js
new file mode 100644
index 00000000..effdd7ff
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_examples_registry.js
@@ -0,0 +1,27 @@
+odoo.define('web.kanban_examples_registry', function (require) {
+"use strict";
+
+/**
+ * This file instantiates and exports a registry. The purpose of this registry
+ * is to store static data displayed in a dialog to help the end user to
+ * configure its columns in the grouped Kanban view.
+ *
+ * To activate a link on the ColumnQuickCreate widget on open such a dialog, the
+ * attribute 'examples' on the root arch node must be set to a valid key in this
+ * registry.
+ *
+ * Each value in this registry must be an array of Objects containing the
+ * following keys:
+ * - name (string)
+ * - columns (Array[string])
+ * - description (string, optional) BE CAREFUL [*]
+ *
+ * [*] The description is added with a t-raw so the translated texts must be
+ * properly escaped.
+ */
+
+var Registry = require('web.Registry');
+
+return new Registry();
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js
new file mode 100644
index 00000000..7dcfe408
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_model.js
@@ -0,0 +1,445 @@
+odoo.define('web.KanbanModel', function (require) {
+"use strict";
+
+/**
+ * The KanbanModel extends the BasicModel to add Kanban specific features like
+ * moving a record from a group to another, resequencing records...
+ */
+
+var BasicModel = require('web.BasicModel');
+var viewUtils = require('web.viewUtils');
+
+var KanbanModel = BasicModel.extend({
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds a record to a group in the localData, and fetch the record.
+ *
+ * @param {string} groupID localID of the group
+ * @param {integer} resId id of the record
+ * @returns {Promise<string>} resolves to the local id of the new record
+ */
+ addRecordToGroup: function (groupID, resId) {
+ var self = this;
+ var group = this.localData[groupID];
+ var new_record = this._makeDataPoint({
+ res_id: resId,
+ modelName: group.model,
+ fields: group.fields,
+ fieldsInfo: group.fieldsInfo,
+ viewType: group.viewType,
+ parentID: groupID,
+ });
+
+ var def = this._fetchRecord(new_record).then(function (result) {
+ group.data.unshift(new_record.id);
+ group.res_ids.unshift(resId);
+ group.count++;
+
+ // update the res_ids and count of the parent
+ self.localData[group.parentID].count++;
+ self._updateParentResIDs(group);
+
+ return result.id;
+ });
+ return this._reloadProgressBarGroupFromRecord(new_record.id, def);
+ },
+ /**
+ * Creates a new group from a name (performs a name_create).
+ *
+ * @param {string} name
+ * @param {string} parentID localID of the parent of the group
+ * @returns {Promise<string>} resolves to the local id of the new group
+ */
+ createGroup: function (name, parentID) {
+ var self = this;
+ var parent = this.localData[parentID];
+ var groupBy = parent.groupedBy[0];
+ var groupByField = parent.fields[groupBy];
+ if (!groupByField || groupByField.type !== 'many2one') {
+ return Promise.reject(); // only supported when grouped on m2o
+ }
+ return this._rpc({
+ model: groupByField.relation,
+ method: 'name_create',
+ args: [name],
+ context: parent.context, // todo: combine with view context
+ })
+ .then(function (result) {
+ const createGroupDataPoint = (model, parent) => {
+ const newGroup = model._makeDataPoint({
+ modelName: parent.model,
+ context: parent.context,
+ domain: parent.domain.concat([[groupBy, "=", result[0]]]),
+ fields: parent.fields,
+ fieldsInfo: parent.fieldsInfo,
+ isOpen: true,
+ limit: parent.limit,
+ parentID: parent.id,
+ openGroupByDefault: true,
+ orderedBy: parent.orderedBy,
+ value: result,
+ viewType: parent.viewType,
+ });
+ if (parent.progressBar) {
+ newGroup.progressBarValues = _.extend({
+ counts: {},
+ }, parent.progressBar);
+ }
+ return newGroup;
+ };
+ const newGroup = createGroupDataPoint(self, parent);
+ parent.data.push(newGroup.id);
+ if (self.isInSampleMode()) {
+ // in sample mode, create the new group in both models (main + sample)
+ const sampleParent = self.sampleModel.localData[parentID];
+ const newSampleGroup = createGroupDataPoint(self.sampleModel, sampleParent);
+ sampleParent.data.push(newSampleGroup.id);
+ }
+ return newGroup.id;
+ });
+ },
+ /**
+ * Creates a new record from the given value, and add it to the given group.
+ *
+ * @param {string} groupID
+ * @param {Object} values
+ * @returns {Promise} resolved with the local id of the created record
+ */
+ createRecordInGroup: function (groupID, values) {
+ var self = this;
+ var group = this.localData[groupID];
+ var context = this._getContext(group);
+ var parent = this.localData[group.parentID];
+ var groupedBy = parent.groupedBy;
+ context['default_' + groupedBy] = viewUtils.getGroupValue(group, groupedBy);
+ var def;
+ if (Object.keys(values).length === 1 && 'display_name' in values) {
+ // only 'display_name is given, perform a 'name_create'
+ def = this._rpc({
+ model: parent.model,
+ method: 'name_create',
+ args: [values.display_name],
+ context: context,
+ }).then(function (records) {
+ return records[0];
+ });
+ } else {
+ // other fields are specified, perform a classical 'create'
+ def = this._rpc({
+ model: parent.model,
+ method: 'create',
+ args: [values],
+ context: context,
+ });
+ }
+ return def.then(function (resID) {
+ return self.addRecordToGroup(group.id, resID);
+ });
+ },
+ /**
+ * Add the following (kanban specific) keys when performing a `get`:
+ *
+ * - tooltipData
+ * - progressBarValues
+ * - isGroupedByM2ONoColumn
+ *
+ * @override
+ * @see _readTooltipFields
+ * @returns {Object}
+ */
+ __get: function () {
+ var result = this._super.apply(this, arguments);
+ var dp = result && this.localData[result.id];
+ if (dp) {
+ if (dp.tooltipData) {
+ result.tooltipData = $.extend(true, {}, dp.tooltipData);
+ }
+ if (dp.progressBarValues) {
+ result.progressBarValues = $.extend(true, {}, dp.progressBarValues);
+ }
+ if (dp.fields[dp.groupedBy[0]]) {
+ var groupedByM2O = dp.fields[dp.groupedBy[0]].type === 'many2one';
+ result.isGroupedByM2ONoColumn = !dp.data.length && groupedByM2O;
+ } else {
+ result.isGroupedByM2ONoColumn = false;
+ }
+ }
+ return result;
+ },
+ /**
+ * Same as @see get but getting the parent element whose ID is given.
+ *
+ * @param {string} id
+ * @returns {Object}
+ */
+ getColumn: function (id) {
+ var element = this.localData[id];
+ if (element) {
+ return this.get(element.parentID);
+ }
+ return null;
+ },
+ /**
+ * @override
+ */
+ __load: function (params) {
+ this.defaultGroupedBy = params.groupBy || [];
+ params.groupedBy = (params.groupedBy && params.groupedBy.length) ? params.groupedBy : this.defaultGroupedBy;
+ return this._super(params);
+ },
+ /**
+ * Load more records in a group.
+ *
+ * @param {string} groupID localID of the group
+ * @returns {Promise<string>} resolves to the localID of the group
+ */
+ loadMore: function (groupID) {
+ var group = this.localData[groupID];
+ var offset = group.loadMoreOffset + group.limit;
+ return this.reload(group.id, {
+ loadMoreOffset: offset,
+ });
+ },
+ /**
+ * Moves a record from a group to another.
+ *
+ * @param {string} recordID localID of the record
+ * @param {string} groupID localID of the new group of the record
+ * @param {string} parentID localID of the parent
+ * @returns {Promise<string[]>} resolves to a pair [oldGroupID, newGroupID]
+ */
+ moveRecord: function (recordID, groupID, parentID) {
+ var self = this;
+ var parent = this.localData[parentID];
+ var new_group = this.localData[groupID];
+ var changes = {};
+ var groupedFieldName = parent.groupedBy[0];
+ var groupedField = parent.fields[groupedFieldName];
+ if (groupedField.type === 'many2one') {
+ changes[groupedFieldName] = {
+ id: new_group.res_id,
+ display_name: new_group.value,
+ };
+ } else if (groupedField.type === 'selection') {
+ var value = _.findWhere(groupedField.selection, {1: new_group.value});
+ changes[groupedFieldName] = value && value[0] || false;
+ } else {
+ changes[groupedFieldName] = new_group.value;
+ }
+
+ // Manually updates groups data. Note: this is done before the actual
+ // save as it might need to perform a read group in some cases so those
+ // updated data might be overridden again.
+ var record = self.localData[recordID];
+ var resID = record.res_id;
+ // Remove record from its current group
+ var old_group;
+ for (var i = 0; i < parent.data.length; i++) {
+ old_group = self.localData[parent.data[i]];
+ var index = _.indexOf(old_group.data, recordID);
+ if (index >= 0) {
+ old_group.data.splice(index, 1);
+ old_group.count--;
+ old_group.res_ids = _.without(old_group.res_ids, resID);
+ self._updateParentResIDs(old_group);
+ break;
+ }
+ }
+ // Add record to its new group
+ new_group.data.push(recordID);
+ new_group.res_ids.push(resID);
+ new_group.count++;
+
+ return this.notifyChanges(recordID, changes).then(function () {
+ return self.save(recordID);
+ }).then(function () {
+ record.parentID = new_group.id;
+ return [old_group.id, new_group.id];
+ });
+ },
+ /**
+ * @override
+ */
+ reload: function (id, options) {
+ // if the groupBy is given in the options and if it is an empty array,
+ // fallback on the default groupBy
+ if (options && options.groupBy && !options.groupBy.length) {
+ options.groupBy = this.defaultGroupedBy;
+ }
+ return this._super(id, options);
+ },
+ /**
+ * @override
+ */
+ __reload: function (id, options) {
+ var def = this._super(id, options);
+ if (options && options.loadMoreOffset) {
+ return def;
+ }
+ return this._reloadProgressBarGroupFromRecord(id, def);
+ },
+ /**
+ * @override
+ */
+ save: function (recordID) {
+ var def = this._super.apply(this, arguments);
+ return this._reloadProgressBarGroupFromRecord(recordID, def);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _makeDataPoint: function (params) {
+ var dataPoint = this._super.apply(this, arguments);
+ if (params.progressBar) {
+ dataPoint.progressBar = params.progressBar;
+ }
+ return dataPoint;
+ },
+ /**
+ * @override
+ */
+ _load: function (dataPoint, options) {
+ if (dataPoint.groupedBy.length && dataPoint.progressBar) {
+ return this._readProgressBarGroup(dataPoint, options);
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Ensures that there is no nested groups in Kanban (only the first grouping
+ * level is taken into account).
+ *
+ * @override
+ * @private
+ * @param {Object} list valid resource object
+ */
+ _readGroup: function (list) {
+ var self = this;
+ if (list.groupedBy.length > 1) {
+ list.groupedBy = [list.groupedBy[0]];
+ }
+ return this._super.apply(this, arguments).then(function (result) {
+ return self._readTooltipFields(list).then(_.constant(result));
+ });
+ },
+ /**
+ * @private
+ * @param {Object} dataPoint
+ * @returns {Promise<Object>}
+ */
+ _readProgressBarGroup: function (list, options) {
+ var self = this;
+ var groupsDef = this._readGroup(list, options);
+ var progressBarDef = this._rpc({
+ model: list.model,
+ method: 'read_progress_bar',
+ kwargs: {
+ domain: list.domain,
+ group_by: list.groupedBy[0],
+ progress_bar: list.progressBar,
+ context: list.context,
+ },
+ });
+ return Promise.all([groupsDef, progressBarDef]).then(function (results) {
+ var data = results[1];
+ _.each(list.data, function (groupID) {
+ var group = self.localData[groupID];
+ group.progressBarValues = _.extend({
+ counts: data[group.value] || {},
+ }, list.progressBar);
+ });
+ return list;
+ });
+ },
+ /**
+ * Fetches tooltip specific fields on the group by relation and stores it in
+ * the column datapoint in a special key `tooltipData`.
+ * Data for the tooltips (group_by_tooltip) are fetched in batch for all
+ * groups, to avoid doing multiple calls.
+ * Data are stored in a special key `tooltipData` on the datapoint.
+ * Note that the option `group_by_tooltip` is only for m2o fields.
+ *
+ * @private
+ * @param {Object} list a list of groups
+ * @returns {Promise}
+ */
+ _readTooltipFields: function (list) {
+ var self = this;
+ var groupedByField = list.fields[list.groupedBy[0].split(':')[0]];
+ if (groupedByField.type !== 'many2one') {
+ return Promise.resolve();
+ }
+ var groupIds = _.reduce(list.data, function (groupIds, id) {
+ var res_id = self.get(id, {raw: true}).res_id;
+ // The field on which we are grouping might not be set on all records
+ if (res_id) {
+ groupIds.push(res_id);
+ }
+ return groupIds;
+ }, []);
+ var tooltipFields = [];
+ var groupedByFieldInfo = list.fieldsInfo.kanban[list.groupedBy[0]];
+ if (groupedByFieldInfo && groupedByFieldInfo.options) {
+ tooltipFields = Object.keys(groupedByFieldInfo.options.group_by_tooltip || {});
+ }
+ if (groupIds.length && tooltipFields.length) {
+ var fieldNames = _.union(['display_name'], tooltipFields);
+ return this._rpc({
+ model: groupedByField.relation,
+ method: 'read',
+ args: [groupIds, fieldNames],
+ context: list.context,
+ }).then(function (result) {
+ _.each(list.data, function (id) {
+ var dp = self.localData[id];
+ dp.tooltipData = _.findWhere(result, {id: dp.res_id});
+ });
+ });
+ }
+ return Promise.resolve();
+ },
+ /**
+ * Reloads all progressbar data. This is done after given promise and
+ * insures that the given promise's result is not lost.
+ *
+ * @private
+ * @param {string} recordID
+ * @param {Promise} def
+ * @returns {Promise}
+ */
+ _reloadProgressBarGroupFromRecord: function (recordID, def) {
+ var element = this.localData[recordID];
+ if (element.type === 'list' && !element.parentID) {
+ // we are reloading the whole view, so there is no need to manually
+ // reload the progressbars
+ return def;
+ }
+
+ // If we updated a record, then we must potentially update columns'
+ // progressbars, so we need to load groups info again
+ var self = this;
+ while (element) {
+ if (element.progressBar) {
+ return def.then(function (data) {
+ return self._load(element, {
+ keepEmptyGroups: true,
+ onlyGroups: true,
+ }).then(function () {
+ return data;
+ });
+ });
+ }
+ element = this.localData[element.parentID];
+ }
+ return def;
+ },
+});
+return KanbanModel;
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_record.js b/addons/web/static/src/js/views/kanban/kanban_record.js
new file mode 100644
index 00000000..02dc22e9
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_record.js
@@ -0,0 +1,761 @@
+odoo.define('web.KanbanRecord', function (require) {
+"use strict";
+
+/**
+ * This file defines the KanbanRecord widget, which corresponds to a card in
+ * a Kanban view.
+ */
+var config = require('web.config');
+var core = require('web.core');
+var Domain = require('web.Domain');
+var Dialog = require('web.Dialog');
+var field_utils = require('web.field_utils');
+const FieldWrapper = require('web.FieldWrapper');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+var widgetRegistry = require('web.widget_registry');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var KANBAN_RECORD_COLORS = require('web.basic_fields').FieldColorPicker.prototype.RECORD_COLORS;
+var NB_KANBAN_RECORD_COLORS = KANBAN_RECORD_COLORS.length;
+
+var KanbanRecord = Widget.extend({
+ events: {
+ 'click .oe_kanban_action': '_onKanbanActionClicked',
+ 'click .o_kanban_manage_toggle_button': '_onManageTogglerClicked',
+ },
+ /**
+ * @override
+ */
+ init: function (parent, state, options) {
+ this._super(parent);
+
+ this.fields = state.fields;
+ this.fieldsInfo = state.fieldsInfo.kanban;
+ this.modelName = state.model;
+
+ this.options = options;
+ this.editable = options.editable;
+ this.deletable = options.deletable;
+ this.read_only_mode = options.read_only_mode;
+ this.selectionMode = options.selectionMode;
+ this.qweb = options.qweb;
+ this.subWidgets = {};
+
+ this._setState(state);
+ // avoid quick multiple clicks
+ this._onKanbanActionClicked = _.debounce(this._onKanbanActionClicked, 300, true);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ return Promise.all([this._super.apply(this, arguments), this._render()]);
+ },
+ /**
+ * Called each time the record is attached to the DOM.
+ */
+ on_attach_callback: function () {
+ this.isInDOM = true;
+ _.invoke(this.subWidgets, 'on_attach_callback');
+ },
+ /**
+ * Called each time the record is detached from the DOM.
+ */
+ on_detach_callback: function () {
+ this.isInDOM = false;
+ _.invoke(this.subWidgets, 'on_detach_callback');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Re-renders the record with a new state
+ *
+ * @param {Object} state
+ * @returns {Promise}
+ */
+ update: function (state) {
+ // detach the widgets because the record will empty its $el, which will
+ // remove all event handlers on its descendants, and we want to keep
+ // those handlers alive as we will re-use these widgets
+ _.invoke(_.pluck(this.subWidgets, '$el'), 'detach');
+ this._setState(state);
+ return this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _attachTooltip: function () {
+ var self = this;
+ this.$('[tooltip]').each(function () {
+ var $el = $(this);
+ var tooltip = $el.attr('tooltip');
+ if (tooltip) {
+ $el.tooltip({
+ title: self.qweb.render(tooltip, self.qweb_context)
+ });
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {string} d a stringified domain
+ * @returns {boolean} the domain evaluted with the current values
+ */
+ _computeDomain: function (d) {
+ return new Domain(d).compute(this.state.evalContext);
+ },
+ /**
+ * Generates the color classname from a given variable
+ *
+ * @private
+ * @param {number | string} variable
+ * @return {string} the classname
+ */
+ _getColorClassname: function (variable) {
+ var color = this._getColorID(variable);
+ return 'oe_kanban_color_' + color;
+ },
+ /**
+ * Computes a color id between 0 and 10 from a given value
+ *
+ * @private
+ * @param {number | string} variable
+ * @returns {integer} the color id
+ */
+ _getColorID: function (variable) {
+ if (typeof variable === 'number') {
+ return Math.round(variable) % NB_KANBAN_RECORD_COLORS;
+ }
+ if (typeof variable === 'string') {
+ var index = 0;
+ for (var i = 0; i < variable.length; i++) {
+ index += variable.charCodeAt(i);
+ }
+ return index % NB_KANBAN_RECORD_COLORS;
+ }
+ return 0;
+ },
+ /**
+ * Computes a color name from value
+ *
+ * @private
+ * @param {number | string} variable
+ * @returns {integer} the color name
+ */
+ _getColorname: function (variable) {
+ var colorID = this._getColorID(variable);
+ return KANBAN_RECORD_COLORS[colorID];
+ },
+ file_type_magic_word: {
+ '/': 'jpg',
+ 'R': 'gif',
+ 'i': 'png',
+ 'P': 'svg+xml',
+ },
+ /**
+ * @private
+ * @param {string} model the name of the model
+ * @param {string} field the name of the field
+ * @param {integer} id the id of the resource
+ * @param {string} placeholder
+ * @returns {string} the url of the image
+ */
+ _getImageURL: function (model, field, id, placeholder) {
+ id = (_.isArray(id) ? id[0] : id) || null;
+ var isCurrentRecord = this.modelName === model && (this.recordData.id === id || (!this.recordData.id && !id));
+ var url;
+ if (isCurrentRecord && this.record[field] && this.record[field].raw_value && !utils.is_bin_size(this.record[field].raw_value)) {
+ // Use magic-word technique for detecting image type
+ url = 'data:image/' + this.file_type_magic_word[this.record[field].raw_value[0]] + ';base64,' + this.record[field].raw_value;
+ } else if (placeholder && (!model || !field || !id || (isCurrentRecord && this.record[field] && !this.record[field].raw_value))) {
+ url = placeholder;
+ } else {
+ var session = this.getSession();
+ var params = {
+ model: model,
+ field: field,
+ id: id
+ };
+ if (isCurrentRecord) {
+ params.unique = this.record.__last_update && this.record.__last_update.value.replace(/[^0-9]/g, '');
+ }
+ url = session.url('/web/image', params);
+ }
+ return url;
+ },
+ /**
+ * Triggers up an event to open the record
+ *
+ * @private
+ */
+ _openRecord: function () {
+ if (this.$el.hasClass('o_currently_dragged')) {
+ // this record is currently being dragged and dropped, so we do not
+ // want to open it.
+ return;
+ }
+ var editMode = this.$el.hasClass('oe_kanban_global_click_edit');
+ this.trigger_up('open_record', {
+ id: this.db_id,
+ mode: editMode ? 'edit' : 'readonly',
+ });
+ },
+ /**
+ * Processes each 'field' tag and replaces it by the specified widget, if
+ * any, or directly by the formatted value
+ *
+ * @private
+ */
+ _processFields: function () {
+ var self = this;
+ this.$("field").each(function () {
+ var $field = $(this);
+ var field_name = $field.attr("name");
+ var field_widget = $field.attr("widget");
+
+ // a widget is specified for that field or a field is a many2many ;
+ // in this latest case, we want to display the widget many2manytags
+ // even if it is not specified in the view.
+ if (field_widget || self.fields[field_name].type === 'many2many') {
+ var widget = self.subWidgets[field_name];
+ if (!widget) {
+ // the widget doesn't exist yet, so instanciate it
+ var Widget = self.fieldsInfo[field_name].Widget;
+ if (Widget) {
+ widget = self._processWidget($field, field_name, Widget);
+ self.subWidgets[field_name] = widget;
+ } else if (config.isDebug()) {
+ // the widget is not implemented
+ $field.replaceWith($('<span>', {
+ text: _.str.sprintf(_t('[No widget %s]'), field_widget),
+ }));
+ }
+ } else {
+ // a widget already exists for that field, so reset it with the new state
+ widget.reset(self.state);
+ $field.replaceWith(widget.$el);
+ if (self.isInDOM && widget.on_attach_callback) {
+ widget.on_attach_callback();
+ }
+ }
+ } else {
+ self._processField($field, field_name);
+ }
+ });
+ },
+ /**
+ * Replace a field by its formatted value.
+ *
+ * @private
+ * @param {JQuery} $field
+ * @param {String} field_name
+ * @returns {Jquery} the modified node
+ */
+ _processField: function ($field, field_name) {
+ // no widget specified for that field, so simply use a formatter
+ // note: we could have used the widget corresponding to the field's type, but
+ // it is much more efficient to use a formatter
+ var field = this.fields[field_name];
+ var value = this.recordData[field_name];
+ var options = { data: this.recordData, forceString: true };
+ var formatted_value = field_utils.format[field.type](value, field, options);
+ var $result = $('<span>', {
+ text: formatted_value,
+ });
+ $field.replaceWith($result);
+ this._setFieldDisplay($result, field_name);
+ return $result;
+ },
+ /**
+ * Replace a field by its corresponding widget.
+ *
+ * @private
+ * @param {JQuery} $field
+ * @param {String} field_name
+ * @param {Class} Widget
+ * @returns {Widget} the widget instance
+ */
+ _processWidget: function ($field, field_name, Widget) {
+ // some field's attrs might be record dependent (they start with
+ // 't-att-') and should thus be evaluated, which is done by qweb
+ // we here replace those attrs in the dict of attrs of the state
+ // by their evaluted value, to make it transparent from the
+ // field's widgets point of view
+ // that dict being shared between records, we don't modify it
+ // in place
+ var self = this;
+ var attrs = Object.create(null);
+ _.each(this.fieldsInfo[field_name], function (value, key) {
+ if (_.str.startsWith(key, 't-att-')) {
+ key = key.slice(6);
+ value = $field.attr(key);
+ }
+ attrs[key] = value;
+ });
+ var options = _.extend({}, this.options, { attrs: attrs });
+ let widget;
+ let def;
+ if (utils.isComponent(Widget)) {
+ widget = new FieldWrapper(this, Widget, {
+ fieldName: field_name,
+ record: this.state,
+ options: options,
+ });
+ def = widget.mount(document.createDocumentFragment())
+ .then(() => {
+ $field.replaceWith(widget.$el);
+ });
+ } else {
+ widget = new Widget(this, field_name, this.state, options);
+ def = widget.replace($field);
+ }
+ this.defs.push(def);
+ def.then(function () {
+ self._setFieldDisplay(widget.$el, field_name);
+ });
+ return widget;
+ },
+ _processWidgets: function () {
+ var self = this;
+ this.$("widget").each(function () {
+ var $field = $(this);
+ var Widget = widgetRegistry.get($field.attr('name'));
+ var widget = new Widget(self, self.state);
+
+ var def = widget._widgetRenderAndInsert(function () { });
+ self.defs.push(def);
+ def.then(function () {
+ $field.replaceWith(widget.$el);
+ widget.$el.addClass('o_widget');
+ });
+ });
+ },
+ /**
+ * Renders the record
+ *
+ * @returns {Promise}
+ */
+ _render: function () {
+ this.defs = [];
+ // call 'on_detach_callback' on each subwidget as they will be removed
+ // from the DOM at the next line
+ _.invoke(this.subWidgets, 'on_detach_callback');
+ this._replaceElement(this.qweb.render('kanban-box', this.qweb_context));
+ this.$el.addClass('o_kanban_record').attr("tabindex", 0);
+ this.$el.attr('role', 'article');
+ this.$el.data('record', this);
+ // forcefully add class oe_kanban_global_click to have clickable record always to select it
+ if (this.selectionMode) {
+ this.$el.addClass('oe_kanban_global_click');
+ }
+ if (this.$el.hasClass('oe_kanban_global_click') ||
+ this.$el.hasClass('oe_kanban_global_click_edit')) {
+ this.$el.on('click', this._onGlobalClick.bind(this));
+ this.$el.on('keydown', this._onKeyDownCard.bind(this));
+ } else {
+ this.$el.on('keydown', this._onKeyDownOpenFirstLink.bind(this));
+ }
+ this._processFields();
+ this._processWidgets();
+ this._setupColor();
+ this._setupColorPicker();
+ this._attachTooltip();
+
+ // We use boostrap tooltips for better and faster display
+ this.$('span.o_tag').tooltip({ delay: { 'show': 50 } });
+
+ return Promise.all(this.defs);
+ },
+ /**
+ * Sets cover image on a kanban card through an attachment dialog.
+ *
+ * @private
+ * @param {string} fieldName field used to set cover image
+ * @param {boolean} autoOpen automatically open the file choser if there are no attachments
+ */
+ _setCoverImage: function (fieldName, autoOpen) {
+ var self = this;
+ this._rpc({
+ model: 'ir.attachment',
+ method: 'search_read',
+ domain: [
+ ['res_model', '=', this.modelName],
+ ['res_id', '=', this.id],
+ ['mimetype', 'ilike', 'image']
+ ],
+ fields: ['id', 'name'],
+ }).then(function (attachmentIds) {
+ self.imageUploadID = _.uniqueId('o_cover_image_upload');
+ self.accepted_file_extensions = 'image/*'; // prevent uploading of other file types
+ self.attachment_count = attachmentIds.length;
+ var coverId = self.record[fieldName] && self.record[fieldName].raw_value;
+ var $content = $(QWeb.render('KanbanView.SetCoverModal', {
+ coverId: coverId,
+ attachmentIds: attachmentIds,
+ widget: self,
+ }));
+ var $imgs = $content.find('.o_kanban_cover_image');
+ var dialog = new Dialog(self, {
+ title: _t("Set a Cover Image"),
+ $content: $content,
+ buttons: [{
+ text: _t("Select"),
+ classes: attachmentIds.length ? 'btn-primary' : 'd-none',
+ close: true,
+ disabled: !coverId,
+ click: function () {
+ var $img = $imgs.filter('.o_selected').find('img');
+ var data = {};
+ data[fieldName] = {
+ id: $img.data('id'),
+ display_name: $img.data('name')
+ };
+ self.trigger_up('kanban_record_update', data);
+ },
+ }, {
+ text: _t('Upload and Set'),
+ classes: attachmentIds.length ? '' : 'btn-primary',
+ close: false,
+ click: function () {
+ $content.find('input.o_input_file').click();
+ },
+ }, {
+ text: _t("Remove Cover Image"),
+ classes: coverId ? '' : 'd-none',
+ close: true,
+ click: function () {
+ var data = {};
+ data[fieldName] = false;
+ self.trigger_up('kanban_record_update', data);
+ },
+ }, {
+ text: _t("Discard"),
+ close: true,
+ }],
+ });
+ dialog.opened().then(function () {
+ var $selectBtn = dialog.$footer.find('.btn-primary');
+ if (autoOpen && !self.attachment_count) {
+ $selectBtn.click();
+ }
+
+ $content.on('click', '.o_kanban_cover_image', function (ev) {
+ $imgs.not(ev.currentTarget).removeClass('o_selected');
+ $selectBtn.prop('disabled', !$(ev.currentTarget).toggleClass('o_selected').hasClass('o_selected'));
+ });
+
+ $content.on('dblclick', '.o_kanban_cover_image', function (ev) {
+ var $img = $(ev.currentTarget).find('img');
+ var data = {};
+ data[fieldName] = {
+ id: $img.data('id'),
+ display_name: $img.data('name')
+ };
+ self.trigger_up('kanban_record_update', data);
+ dialog.close();
+ });
+ $content.on('change', 'input.o_input_file', function () {
+ $content.find('form.o_form_binary_form').submit();
+ });
+ $(window).on(self.imageUploadID, function () {
+ var images = Array.prototype.slice.call(arguments, 1);
+ var data = {};
+ data[fieldName] = {
+ id: images[0].id,
+ display_name: images[0].filename
+ };
+ self.trigger_up('kanban_record_update', data);
+ dialog.close();
+ });
+ });
+ dialog.open();
+ });
+ },
+ /**
+ * Sets particular classnames on a field $el according to the
+ * field's attrs (display or bold attributes)
+ *
+ * @private
+ * @param {JQuery} $el
+ * @param {string} fieldName
+ */
+ _setFieldDisplay: function ($el, fieldName) {
+ // attribute display
+ if (this.fieldsInfo[fieldName].display === 'right') {
+ $el.addClass('float-right');
+ } else if (this.fieldsInfo[fieldName].display === 'full') {
+ $el.addClass('o_text_block');
+ }
+
+ // attribute bold
+ if (this.fieldsInfo[fieldName].bold) {
+ $el.addClass('o_text_bold');
+ }
+ },
+ /**
+ * Sets internal values of the kanban record according to the given state
+ *
+ * @private
+ * @param {Object} recordState
+ */
+ _setState: function (recordState) {
+ this.state = recordState;
+ this.id = recordState.res_id;
+ this.db_id = recordState.id;
+ this.recordData = recordState.data;
+ this.record = this._transformRecord(recordState.data);
+ this.qweb_context = {
+ context: this.state.getContext(),
+ kanban_image: this._getImageURL.bind(this),
+ kanban_color: this._getColorClassname.bind(this),
+ kanban_getcolor: this._getColorID.bind(this),
+ kanban_getcolorname: this._getColorname.bind(this),
+ kanban_compute_domain: this._computeDomain.bind(this),
+ selection_mode: this.selectionMode,
+ read_only_mode: this.read_only_mode,
+ record: this.record,
+ user_context: this.getSession().user_context,
+ widget: this,
+ };
+ },
+ /**
+ * If an attribute `color` is set on the kanban record, adds the
+ * corresponding color classname.
+ *
+ * @private
+ */
+ _setupColor: function () {
+ var color_field = this.$el.attr('color');
+ if (color_field && color_field in this.fields) {
+ var colorHelp = _.str.sprintf(_t("Card color: %s"), this._getColorname(this.recordData[color_field]));
+ var colorClass = this._getColorClassname(this.recordData[color_field]);
+ this.$el.addClass(colorClass);
+ this.$el.prepend('<span title="' + colorHelp + '" aria-label="' + colorHelp + '" role="img" class="oe_kanban_color_help"/>');
+ }
+ },
+ /**
+ * Renders the color picker in the kanban record, and binds the event handler
+ *
+ * @private
+ */
+ _setupColorPicker: function () {
+ var $colorpicker = this.$('ul.oe_kanban_colorpicker');
+ if (!$colorpicker.length) {
+ return;
+ }
+ $colorpicker.html(QWeb.render('KanbanColorPicker', { colors: KANBAN_RECORD_COLORS}));
+ $colorpicker.on('click', 'a', this._onColorChanged.bind(this));
+ },
+ /**
+ * Builds an object containing the formatted record data used in the
+ * template
+ *
+ * @private
+ * @param {Object} recordData
+ * @returns {Object} transformed record data
+ */
+ _transformRecord: function (recordData) {
+ var self = this;
+ var new_record = {};
+ _.each(this.state.getFieldNames(), function (name) {
+ var value = recordData[name];
+ var r = _.clone(self.fields[name] || {});
+
+ if ((r.type === 'date' || r.type === 'datetime') && value) {
+ r.raw_value = value.toDate();
+ } else if (r.type === 'one2many' || r.type === 'many2many') {
+ r.raw_value = value.count ? value.res_ids : [];
+ } else if (r.type === 'many2one') {
+ r.raw_value = value && value.res_id || false;
+ } else {
+ r.raw_value = value;
+ }
+
+ if (r.type) {
+ var formatter = field_utils.format[r.type];
+ r.value = formatter(value, self.fields[name], recordData, self.state);
+ } else {
+ r.value = value;
+ }
+
+ new_record[name] = r;
+ });
+ return new_record;
+ },
+ /**
+ * Notifies the controller that the record has changed
+ *
+ * @private
+ * @param {Object} data the new values
+ */
+ _updateRecord: function (data) {
+ this.trigger_up('kanban_record_update', data);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onColorChanged: function (event) {
+ event.preventDefault();
+ var data = {};
+ var color_field = $(event.delegateTarget).data('field') || 'color';
+ data[color_field] = $(event.currentTarget).data('color');
+ this.trigger_up('kanban_record_update', data);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onGlobalClick: function (event) {
+ if ($(event.target).parents('.o_dropdown_kanban').length) {
+ return;
+ }
+ var trigger = true;
+ var elem = event.target;
+ var ischild = true;
+ var children = [];
+ while (elem) {
+ var events = $._data(elem, 'events');
+ if (elem === event.currentTarget) {
+ ischild = false;
+ }
+ var test_event = events && events.click && (events.click.length > 1 || events.click[0].namespace !== 'bs.tooltip');
+ var testLinkWithHref = elem.nodeName.toLowerCase() === 'a' && elem.href;
+ if (ischild) {
+ children.push(elem);
+ if (test_event || testLinkWithHref) {
+ // Do not trigger global click if one child has a click
+ // event registered (or it is a link with href)
+ trigger = false;
+ }
+ }
+ if (trigger && test_event) {
+ _.each(events.click, function (click_event) {
+ if (click_event.selector) {
+ // For each parent of original target, check if a
+ // delegated click is bound to any previously found children
+ _.each(children, function (child) {
+ if ($(child).is(click_event.selector)) {
+ trigger = false;
+ }
+ });
+ }
+ });
+ }
+ elem = elem.parentElement;
+ }
+ if (trigger) {
+ this._openRecord();
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onKanbanActionClicked: function (event) {
+ event.preventDefault();
+
+ var $action = $(event.currentTarget);
+ var type = $action.data('type') || 'button';
+
+ switch (type) {
+ case 'edit':
+ this.trigger_up('open_record', { id: this.db_id, mode: 'edit' });
+ break;
+ case 'open':
+ this.trigger_up('open_record', { id: this.db_id });
+ break;
+ case 'delete':
+ this.trigger_up('kanban_record_delete', { id: this.db_id, record: this });
+ break;
+ case 'action':
+ case 'object':
+ var attrs = $action.data();
+ attrs.confirm = $action.attr('confirm');
+ this.trigger_up('button_clicked', {
+ attrs: attrs,
+ record: this.state,
+ });
+ break;
+ case 'set_cover':
+ var fieldName = $action.data('field');
+ var autoOpen = $action.data('auto-open');
+ if (this.fields[fieldName].type === 'many2one' &&
+ this.fields[fieldName].relation === 'ir.attachment' &&
+ this.fieldsInfo[fieldName].widget === 'attachment_image') {
+ this._setCoverImage(fieldName, autoOpen);
+ } else {
+ var warning = _.str.sprintf(_t('Could not set the cover image: incorrect field ("%s") is provided in the view.'), fieldName);
+ this.do_warn(warning);
+ }
+ break;
+ default:
+ this.do_warn(false, _t("Kanban: no action for type: ") + type);
+ }
+ },
+ /**
+ * This event is linked to the kanban card when there is a global_click
+ * class on this card
+ *
+ * @private
+ * @param {KeyDownEvent} event
+ */
+ _onKeyDownCard: function (event) {
+ switch (event.keyCode) {
+ case $.ui.keyCode.ENTER:
+ if ($(event.target).hasClass('oe_kanban_global_click')) {
+ event.preventDefault();
+ this._onGlobalClick(event);
+ break;
+ }
+ }
+ },
+ /**
+ * This event is linked ot the kanban card when there is no global_click
+ * class on the card
+ *
+ * @private
+ * @param {KeyDownEvent} event
+ */
+ _onKeyDownOpenFirstLink: function (event) {
+ switch (event.keyCode) {
+ case $.ui.keyCode.ENTER:
+ event.preventDefault();
+ $(event.target).find('a, button').first().click();
+ break;
+ }
+ },
+ /**
+ * Toggles the configuration panel of the record
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onManageTogglerClicked: function (event) {
+ event.preventDefault();
+ this.$el.parent().find('.o_kanban_record').not(this.$el).removeClass('o_dropdown_open');
+ this.$el.toggleClass('o_dropdown_open');
+ var colorClass = this._getColorClassname(this.recordData.color || 0);
+ this.$('.o_kanban_manage_button_section').toggleClass(colorClass);
+ },
+});
+
+return KanbanRecord;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js
new file mode 100644
index 00000000..e7206917
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js
@@ -0,0 +1,315 @@
+odoo.define('web.kanban_record_quick_create', function (require) {
+"use strict";
+
+/**
+ * This file defines the RecordQuickCreate widget for Kanban. It allows to
+ * create kanban records directly from the Kanban view.
+ */
+
+var core = require('web.core');
+var QuickCreateFormView = require('web.QuickCreateFormView');
+const session = require('web.session');
+var Widget = require('web.Widget');
+
+var RecordQuickCreate = Widget.extend({
+ className: 'o_kanban_quick_create',
+ custom_events: {
+ add: '_onAdd',
+ cancel: '_onCancel',
+ },
+ events: {
+ 'click .o_kanban_add': '_onAddClicked',
+ 'click .o_kanban_edit': '_onEditClicked',
+ 'click .o_kanban_cancel': '_onCancelClicked',
+ 'mousedown': '_onMouseDown',
+ },
+ mouseDownInside: false,
+
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Object} options
+ * @param {Object} options.context
+ * @param {string|null} options.formViewRef
+ * @param {string} options.model
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.context = options.context;
+ this.formViewRef = options.formViewRef;
+ this.model = options.model;
+ this._disabled = false; // to prevent from creating multiple records (e.g. on double-clicks)
+ },
+ /**
+ * Loads the form fieldsView (if not provided), instantiates the form view
+ * and starts the form controller.
+ *
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ var superWillStart = this._super.apply(this, arguments);
+ var viewsLoaded;
+ if (this.formViewRef) {
+ var views = [[false, 'form']];
+ var context = _.extend({}, this.context, {
+ form_view_ref: this.formViewRef,
+ });
+ viewsLoaded = this.loadViews(this.model, context, views);
+ } else {
+ var fieldsView = {};
+ fieldsView.arch = '<form>' +
+ '<field name="display_name" placeholder="Title" modifiers=\'{"required": true}\'/>' +
+ '</form>';
+ var fields = {
+ display_name: {string: 'Display name', type: 'char'},
+ };
+ fieldsView.fields = fields;
+ fieldsView.viewFields = fields;
+ viewsLoaded = Promise.resolve({form: fieldsView});
+ }
+ viewsLoaded = viewsLoaded.then(function (fieldsViews) {
+ var formView = new QuickCreateFormView(fieldsViews.form, {
+ context: self.context,
+ modelName: self.model,
+ userContext: session.user_context,
+ });
+ return formView.getController(self).then(function (controller) {
+ self.controller = controller;
+ return self.controller.appendTo(document.createDocumentFragment());
+ });
+ });
+ return Promise.all([superWillStart, viewsLoaded]);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$el.append(this.controller.$el);
+ this.controller.renderButtons(this.$el);
+
+ // focus the first field
+ this.controller.autofocus();
+
+ // destroy the quick create when the user clicks outside
+ core.bus.on('click', this, this._onWindowClicked);
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Called when the quick create is appended into the DOM.
+ */
+ on_attach_callback: function () {
+ if (this.controller) {
+ this.controller.autofocus();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Cancels the quick creation if the record isn't dirty, i.e. if no changes
+ * have been made yet
+ *
+ * @private
+ * @returns {Promise}
+ */
+ cancel: function () {
+ var self = this;
+ return this.controller.commitChanges().then(function () {
+ if (!self.controller.isDirty()) {
+ self._cancel();
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} [options]
+ * @param {boolean} [options.openRecord] set to true to directly open the
+ * newly created record in a form view (in edit mode)
+ */
+ _add: function (options) {
+ var self = this;
+ if (this._disabled) {
+ // don't do anything if we are already creating a record
+ return;
+ }
+ // disable the widget to prevent the user from creating multiple records
+ // with the current values ; if the create works, the widget will be
+ // destroyed and another one will be instantiated, so there is no need
+ // to re-enable it in that case
+ this._disableQuickCreate();
+ this.controller.commitChanges().then(function () {
+ var canBeSaved = self.controller.canBeSaved();
+ if (canBeSaved) {
+ self.trigger_up('quick_create_add_record', {
+ openRecord: options && options.openRecord || false,
+ values: self.controller.getChanges(),
+ onFailure: self._enableQuickCreate.bind(self),
+ });
+ } else {
+ self._enableQuickCreate();
+ }
+ }).guardedCatch(this._enableQuickCreate.bind(this));
+ },
+ /**
+ * Notifies the environment that the quick creation must be cancelled
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _cancel: function () {
+ this.trigger_up('cancel_quick_create');
+ },
+ /**
+ * Disable the widget to indicate the user that it can't interact with it.
+ * This function must be called when a record is being created, to prevent
+ * it from being created twice.
+ *
+ * Note that if the record creation works as expected, there is no need to
+ * re-enable the widget as it will be destroyed anyway (and replaced by a
+ * new instance).
+ *
+ * @private
+ */
+ _disableQuickCreate: function () {
+ this._disabled = true; // ensures that the record won't be created twice
+ this.$el.addClass('o_disabled');
+ this.$('input:not(:disabled)')
+ .addClass('o_temporarily_disabled')
+ .attr('disabled', 'disabled');
+ },
+ /**
+ * Re-enable the widget to allow the user to create again.
+ *
+ * @private
+ */
+ _enableQuickCreate: function () {
+ this._disabled = false; // allows to create again
+ this.$el.removeClass('o_disabled');
+ this.$('input.o_temporarily_disabled')
+ .removeClass('o_temporarily_disabled')
+ .attr('disabled', false);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onAdd: function (ev) {
+ ev.stopPropagation();
+ this._add();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddClicked: function (ev) {
+ ev.stopPropagation();
+ this._add();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onCancel: function (ev) {
+ ev.stopPropagation();
+ this._cancel();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onCancelClicked: function (ev) {
+ ev.stopPropagation();
+ this._cancel();
+ },
+ /**
+ * Validates the quick creation and directly opens the record in a form
+ * view in edit mode.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onEditClicked: function (ev) {
+ ev.stopPropagation();
+ this._add({openRecord: true});
+ },
+ /**
+ * When a click happens outside the quick create, we want to close the quick
+ * create.
+ *
+ * This is quite tricky, because in some cases a click is performed outside
+ * the quick create, but is still related to it (e.g. click in a dialog
+ * opened from the quick create).
+ *
+ * @param {MouseEvent} ev
+ */
+ _onWindowClicked: function (ev) {
+ var mouseDownInside = this.mouseDownInside;
+
+ this.mouseDownInside = false;
+ // ignore clicks if the quick create is not in the dom
+ if (!document.contains(this.el)) {
+ return;
+ }
+
+ // ignore clicks on elements that open the quick create widget, to
+ // prevent from closing quick create widget that has just been opened
+ if ($(ev.target).closest('.o-kanban-button-new, .o_kanban_quick_add').length) {
+ return;
+ }
+
+ // ignore clicks in autocomplete dropdowns
+ if ($(ev.target).parents('.ui-autocomplete').length) {
+ return;
+ }
+
+ // ignore clicks in modals
+ if ($(ev.target).closest('.modal').length) {
+ return;
+ }
+
+ // ignore clicks while a modal is just about to open
+ if ($(document.body).hasClass('modal-open')) {
+ return;
+ }
+
+ // ignore clicks if target is no longer in dom (e.g., a click on the
+ // 'delete' trash icon of a m2m tag)
+ if (!document.contains(ev.target)) {
+ return;
+ }
+
+ // ignore clicks if target is inside the quick create
+ if (this.el.contains(ev.target) || this.el === ev.target || mouseDownInside) {
+ return;
+ }
+
+ this.cancel();
+ },
+ /**
+ * Detects if the click is originally from the quick create
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMouseDown: function(ev){
+ this.mouseDownInside = true;
+ }
+});
+
+return RecordQuickCreate;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js
new file mode 100644
index 00000000..dfaba0a1
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -0,0 +1,684 @@
+odoo.define('web.KanbanRenderer', function (require) {
+"use strict";
+
+var BasicRenderer = require('web.BasicRenderer');
+var ColumnQuickCreate = require('web.kanban_column_quick_create');
+var config = require('web.config');
+var core = require('web.core');
+var KanbanColumn = require('web.KanbanColumn');
+var KanbanRecord = require('web.KanbanRecord');
+var QWeb = require('web.QWeb');
+var session = require('web.session');
+var utils = require('web.utils');
+var viewUtils = require('web.viewUtils');
+
+var qweb = core.qweb;
+var _t = core._t;
+
+function findInNode(node, predicate) {
+ if (predicate(node)) {
+ return node;
+ }
+ if (!node.children) {
+ return undefined;
+ }
+ for (var i = 0; i < node.children.length; i++) {
+ if (findInNode(node.children[i], predicate)) {
+ return node.children[i];
+ }
+ }
+}
+
+function qwebAddIf(node, condition) {
+ if (node.attrs[qweb.prefix + '-if']) {
+ condition = _.str.sprintf("(%s) and (%s)", node.attrs[qweb.prefix + '-if'], condition);
+ }
+ node.attrs[qweb.prefix + '-if'] = condition;
+}
+
+function transformQwebTemplate(node, fields) {
+ // Process modifiers
+ if (node.tag && node.attrs.modifiers) {
+ var modifiers = node.attrs.modifiers || {};
+ if (modifiers.invisible) {
+ qwebAddIf(node, _.str.sprintf("!kanban_compute_domain(%s)", JSON.stringify(modifiers.invisible)));
+ }
+ }
+ switch (node.tag) {
+ case 'button':
+ case 'a':
+ var type = node.attrs.type || '';
+ if (_.indexOf('action,object,edit,open,delete,url,set_cover'.split(','), type) !== -1) {
+ _.each(node.attrs, function (v, k) {
+ if (_.indexOf('icon,type,name,args,string,context,states,kanban_states'.split(','), k) !== -1) {
+ node.attrs['data-' + k] = v;
+ delete(node.attrs[k]);
+ }
+ });
+ if (node.attrs['data-string']) {
+ node.attrs.title = node.attrs['data-string'];
+ }
+ if (node.tag === 'a' && node.attrs['data-type'] !== "url") {
+ node.attrs.href = '#';
+ } else {
+ node.attrs.type = 'button';
+ }
+
+ var action_classes = " oe_kanban_action oe_kanban_action_" + node.tag;
+ if (node.attrs['t-attf-class']) {
+ node.attrs['t-attf-class'] += action_classes;
+ } else if (node.attrs['t-att-class']) {
+ node.attrs['t-att-class'] += " + '" + action_classes + "'";
+ } else {
+ node.attrs['class'] = (node.attrs['class'] || '') + action_classes;
+ }
+ }
+ break;
+ }
+ if (node.children) {
+ for (var i = 0, ii = node.children.length; i < ii; i++) {
+ transformQwebTemplate(node.children[i], fields);
+ }
+ }
+}
+
+var KanbanRenderer = BasicRenderer.extend({
+ className: 'o_kanban_view',
+ config: { // the KanbanRecord and KanbanColumn classes to use (may be overridden)
+ KanbanColumn: KanbanColumn,
+ KanbanRecord: KanbanRecord,
+ },
+ custom_events: _.extend({}, BasicRenderer.prototype.custom_events || {}, {
+ close_quick_create: '_onCloseQuickCreate',
+ cancel_quick_create: '_onCancelQuickCreate',
+ set_progress_bar_state: '_onSetProgressBarState',
+ start_quick_create: '_onStartQuickCreate',
+ quick_create_column_updated: '_onQuickCreateColumnUpdated',
+ }),
+ events:_.extend({}, BasicRenderer.prototype.events || {}, {
+ 'keydown .o_kanban_record' : '_onRecordKeyDown'
+ }),
+ sampleDataTargets: [
+ '.o_kanban_counter',
+ '.o_kanban_record',
+ '.o_kanban_toggle_fold',
+ '.o_column_folded',
+ '.o_column_archive_records',
+ '.o_column_unarchive_records',
+ ],
+
+ /**
+ * @override
+ * @param {Object} params
+ * @param {boolean} params.quickCreateEnabled set to false to disable the
+ * quick create feature
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+
+ this.widgets = [];
+ this.qweb = new QWeb(config.isDebug(), {_s: session.origin}, false);
+ var templates = findInNode(this.arch, function (n) { return n.tag === 'templates';});
+ transformQwebTemplate(templates, state.fields);
+ this.qweb.add_template(utils.json_node_to_xml(templates));
+ this.examples = params.examples;
+ this.recordOptions = _.extend({}, params.record_options, {
+ qweb: this.qweb,
+ viewType: 'kanban',
+ });
+ this.columnOptions = _.extend({KanbanRecord: this.config.KanbanRecord}, params.column_options);
+ if (this.columnOptions.hasProgressBar) {
+ this.columnOptions.progressBarStates = {};
+ }
+ this.quickCreateEnabled = params.quickCreateEnabled;
+ if (!params.readOnlyMode) {
+ var handleField = _.findWhere(this.state.fieldsInfo.kanban, {widget: 'handle'});
+ this.handleField = handleField && handleField.name;
+ }
+ this._setState(state);
+ },
+ /**
+ * Called each time the renderer is attached into the DOM.
+ */
+ on_attach_callback: function () {
+ this._super(...arguments);
+ if (this.quickCreate) {
+ this.quickCreate.on_attach_callback();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Displays the quick create record in the requested column (first one by
+ * default)
+ *
+ * @params {string} [groupId] local id of the group in which the quick create
+ * must be inserted
+ * @returns {Promise}
+ */
+ addQuickCreate: function (groupId) {
+ let kanbanColumn;
+ if (groupId) {
+ kanbanColumn = this.widgets.find(column => column.db_id === groupId);
+ }
+ kanbanColumn = kanbanColumn || this.widgets[0];
+ return kanbanColumn.addQuickCreate();
+ },
+ /**
+ * Focuses the first kanban record
+ */
+ giveFocus: function () {
+ this.$('.o_kanban_record:first').focus();
+ },
+ /**
+ * Toggle fold/unfold the Column quick create widget
+ */
+ quickCreateToggleFold: function () {
+ this.quickCreate.toggleFold();
+ this._toggleNoContentHelper();
+ },
+ /**
+ * Updates a given column with its new state.
+ *
+ * @param {string} localID the column id
+ * @param {Object} columnState
+ * @param {Object} [options]
+ * @param {Object} [options.state] if set, this represents the new state
+ * @param {boolean} [options.openQuickCreate] if true, directly opens the
+ * QuickCreate widget in the updated column
+ *
+ * @returns {Promise}
+ */
+ updateColumn: function (localID, columnState, options) {
+ var self = this;
+ var KanbanColumn = this.config.KanbanColumn;
+ var newColumn = new KanbanColumn(this, columnState, this.columnOptions, this.recordOptions);
+ var index = _.findIndex(this.widgets, {db_id: localID});
+ var column = this.widgets[index];
+ this.widgets[index] = newColumn;
+ if (options && options.state) {
+ this._setState(options.state);
+ }
+ return newColumn.appendTo(document.createDocumentFragment()).then(function () {
+ var def;
+ if (options && options.openQuickCreate) {
+ def = newColumn.addQuickCreate();
+ }
+ return Promise.resolve(def).then(function () {
+ newColumn.$el.insertAfter(column.$el);
+ self._toggleNoContentHelper();
+ // When a record has been quick created, the new column directly
+ // renders the quick create widget (to allow quick creating several
+ // records in a row). However, as we render this column in a
+ // fragment, the quick create widget can't be correctly focused. So
+ // we manually call on_attach_callback to focus it once in the DOM.
+ newColumn.on_attach_callback();
+ column.destroy();
+ });
+ });
+ },
+ /**
+ * Updates a given record with its new state.
+ *
+ * @param {Object} recordState
+ * @returns {Promise}
+ */
+ updateRecord: function (recordState) {
+ var isGrouped = !!this.state.groupedBy.length;
+ var record;
+
+ if (isGrouped) {
+ // if grouped, this.widgets are kanban columns so we need to find
+ // the kanban record inside
+ _.each(this.widgets, function (widget) {
+ record = record || _.findWhere(widget.records, {
+ db_id: recordState.id,
+ });
+ });
+ } else {
+ record = _.findWhere(this.widgets, {db_id: recordState.id});
+ }
+
+ if (record) {
+ return record.update(recordState);
+ }
+ return Promise.resolve();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {DOMElement} currentColumn
+ */
+ _focusOnNextCard: function (currentCardElement) {
+ var nextCard = currentCardElement.nextElementSibling;
+ if (nextCard) {
+ nextCard.focus();
+ }
+ },
+ /**
+ * Tries to give focus to the previous card, and returns true if successful
+ *
+ * @private
+ * @param {DOMElement} currentColumn
+ * @returns {boolean}
+ */
+ _focusOnPreviousCard: function (currentCardElement) {
+ var previousCard = currentCardElement.previousElementSibling;
+ if (previousCard && previousCard.classList.contains("o_kanban_record")) { //previous element might be column title
+ previousCard.focus();
+ return true;
+ }
+ },
+ /**
+ * Returns the default columns for the kanban view example background.
+ * You can override this method to easily customize the column names.
+ *
+ * @private
+ */
+ _getGhostColumns: function () {
+ if (this.examples && this.examples.ghostColumns) {
+ return this.examples.ghostColumns;
+ }
+ return _.map(_.range(1, 5), function (num) {
+ return _.str.sprintf(_t("Column %s"), num);
+ });
+ },
+ /**
+ * Render the Example Ghost Kanban card on the background
+ *
+ * @private
+ * @param {DocumentFragment} fragment
+ */
+ _renderExampleBackground: function (fragment) {
+ var $background = $(qweb.render('KanbanView.ExamplesBackground', {ghostColumns: this._getGhostColumns()}));
+ $background.appendTo(fragment);
+ },
+ /**
+ * Renders empty invisible divs in a document fragment.
+ *
+ * @private
+ * @param {DocumentFragment} fragment
+ * @param {integer} nbDivs the number of divs to append
+ * @param {Object} [options]
+ * @param {string} [options.inlineStyle]
+ */
+ _renderGhostDivs: function (fragment, nbDivs, options) {
+ var ghostDefs = [];
+ for (var $ghost, i = 0; i < nbDivs; i++) {
+ $ghost = $('<div>').addClass('o_kanban_record o_kanban_ghost');
+ if (options && options.inlineStyle) {
+ $ghost.attr('style', options.inlineStyle);
+ }
+ var def = $ghost.appendTo(fragment);
+ ghostDefs.push(def);
+ }
+ return Promise.all(ghostDefs);
+ },
+ /**
+ * Renders an grouped kanban view in a fragment.
+ *
+ * @private
+ * @param {DocumentFragment} fragment
+ */
+ _renderGrouped: function (fragment) {
+ var self = this;
+
+ // Render columns
+ var KanbanColumn = this.config.KanbanColumn;
+ _.each(this.state.data, function (group) {
+ var column = new KanbanColumn(self, group, self.columnOptions, self.recordOptions);
+ var def;
+ if (!group.value) {
+ def = column.prependTo(fragment); // display the 'Undefined' group first
+ self.widgets.unshift(column);
+ } else {
+ def = column.appendTo(fragment);
+ self.widgets.push(column);
+ }
+ self.defs.push(def);
+ });
+
+ // remove previous sorting
+ if(this.$el.sortable('instance') !== undefined) {
+ this.$el.sortable('destroy');
+ }
+ if (this.groupedByM2O) {
+ // Enable column sorting
+ this.$el.sortable({
+ axis: 'x',
+ items: '> .o_kanban_group',
+ handle: '.o_column_title',
+ cursor: 'move',
+ revert: 150,
+ delay: 100,
+ tolerance: 'pointer',
+ forcePlaceholderSize: true,
+ stop: function () {
+ var ids = [];
+ self.$('.o_kanban_group').each(function (index, u) {
+ // Ignore 'Undefined' column
+ if (_.isNumber($(u).data('id'))) {
+ ids.push($(u).data('id'));
+ }
+ });
+ self.trigger_up('resequence_columns', {ids: ids});
+ },
+ });
+
+ if (this.createColumnEnabled) {
+ this.quickCreate = new ColumnQuickCreate(this, {
+ applyExamplesText: this.examples && this.examples.applyExamplesText,
+ examples: this.examples && this.examples.examples,
+ });
+ this.defs.push(this.quickCreate.appendTo(fragment).then(function () {
+ // Open it directly if there is no column yet
+ if (!self.state.data.length) {
+ self.quickCreate.toggleFold();
+ self._renderExampleBackground(fragment);
+ }
+ }));
+ }
+ }
+ },
+ /**
+ * Renders an ungrouped kanban view in a fragment.
+ *
+ * @private
+ * @param {DocumentFragment} fragment
+ */
+ _renderUngrouped: function (fragment) {
+ var self = this;
+ var KanbanRecord = this.config.KanbanRecord;
+ var kanbanRecord;
+ _.each(this.state.data, function (record) {
+ kanbanRecord = new KanbanRecord(self, record, self.recordOptions);
+ self.widgets.push(kanbanRecord);
+ var def = kanbanRecord.appendTo(fragment);
+ self.defs.push(def);
+ });
+
+ // enable record resequencing if there is a field with widget='handle'
+ // and if there is no orderBy (in this case we assume that the widget
+ // has been put on the first default order field of the model), or if
+ // the first orderBy field is the one with widget='handle'
+ var orderedBy = this.state.orderedBy;
+ var hasHandle = this.handleField &&
+ (orderedBy.length === 0 || orderedBy[0].name === this.handleField);
+ if (hasHandle) {
+ this.$el.sortable({
+ items: '.o_kanban_record:not(.o_kanban_ghost)',
+ cursor: 'move',
+ revert: 0,
+ delay: 0,
+ tolerance: 'pointer',
+ forcePlaceholderSize: true,
+ stop: function (event, ui) {
+ self._moveRecord(ui.item.data('record').db_id, ui.item.index());
+ },
+ });
+ }
+
+ // append ghost divs to ensure that all kanban records are left aligned
+ var prom = Promise.all(self.defs).then(function () {
+ var options = {};
+ if (kanbanRecord) {
+ options.inlineStyle = kanbanRecord.$el.attr('style');
+ }
+ return self._renderGhostDivs(fragment, 6, options);
+ });
+ this.defs.push(prom);
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderView: function () {
+ var self = this;
+
+ // render the kanban view
+ var isGrouped = !!this.state.groupedBy.length;
+ var fragment = document.createDocumentFragment();
+ var defs = [];
+ this.defs = defs;
+ if (isGrouped) {
+ this._renderGrouped(fragment);
+ } else {
+ this._renderUngrouped(fragment);
+ }
+ delete this.defs;
+
+ return this._super.apply(this, arguments).then(function () {
+ return Promise.all(defs).then(function () {
+ self.$el.empty();
+ self.$el.toggleClass('o_kanban_grouped', isGrouped);
+ self.$el.toggleClass('o_kanban_ungrouped', !isGrouped);
+ self.$el.append(fragment);
+ self._toggleNoContentHelper();
+ });
+ });
+ },
+ /**
+ * @param {boolean} [remove] if true, the nocontent helper is always removed
+ * @private
+ */
+ _toggleNoContentHelper: function (remove) {
+ var displayNoContentHelper =
+ !remove &&
+ !this._hasContent() &&
+ !!this.noContentHelp &&
+ !(this.quickCreate && !this.quickCreate.folded) &&
+ !this.state.isGroupedByM2ONoColumn;
+
+ var $noContentHelper = this.$('.o_view_nocontent');
+
+ if (displayNoContentHelper && !$noContentHelper.length) {
+ this._renderNoContentHelper();
+ }
+ if (!displayNoContentHelper && $noContentHelper.length) {
+ $noContentHelper.remove();
+ }
+ },
+ /**
+ * Sets the current state and updates some internal attributes accordingly.
+ *
+ * @override
+ */
+ _setState: function () {
+ this._super(...arguments);
+
+ var groupByField = this.state.groupedBy[0];
+ var cleanGroupByField = this._cleanGroupByField(groupByField);
+ var groupByFieldAttrs = this.state.fields[cleanGroupByField];
+ var groupByFieldInfo = this.state.fieldsInfo.kanban[cleanGroupByField];
+ // Deactivate the drag'n'drop if the groupedBy field:
+ // - is a date or datetime since we group by month or
+ // - is readonly (on the field attrs or in the view)
+ var draggable = true;
+ var grouped_by_date = false;
+ if (groupByFieldAttrs) {
+ if (groupByFieldAttrs.type === "date" || groupByFieldAttrs.type === "datetime") {
+ draggable = false;
+ grouped_by_date = true;
+ } else if (groupByFieldAttrs.readonly !== undefined) {
+ draggable = !(groupByFieldAttrs.readonly);
+ }
+ }
+ if (groupByFieldInfo) {
+ if (draggable && groupByFieldInfo.readonly !== undefined) {
+ draggable = !(groupByFieldInfo.readonly);
+ }
+ }
+ this.groupedByM2O = groupByFieldAttrs && (groupByFieldAttrs.type === 'many2one');
+ var relation = this.groupedByM2O && groupByFieldAttrs.relation;
+ var groupByTooltip = groupByFieldInfo && groupByFieldInfo.options.group_by_tooltip;
+ this.columnOptions = _.extend(this.columnOptions, {
+ draggable: draggable,
+ group_by_tooltip: groupByTooltip,
+ groupedBy: groupByField,
+ grouped_by_m2o: this.groupedByM2O,
+ grouped_by_date: grouped_by_date,
+ relation: relation,
+ quick_create: this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(this.state),
+ });
+ this.createColumnEnabled = this.groupedByM2O && this.columnOptions.group_creatable;
+ },
+ /**
+ * Remove date/datetime magic grouping info to get proper field attrs/info from state
+ * ex: sent_date:month will become sent_date
+ *
+ * @private
+ * @param {string} groupByField
+ * @returns {string}
+ */
+ _cleanGroupByField: function (groupByField) {
+ var cleanGroupByField = groupByField;
+ if (cleanGroupByField && cleanGroupByField.indexOf(':') > -1) {
+ cleanGroupByField = cleanGroupByField.substring(0, cleanGroupByField.indexOf(':'));
+ }
+
+ return cleanGroupByField;
+ },
+ /**
+ * Moves the focus on the first card of the next column in a given direction
+ * This ignores the folded columns and skips over the empty columns.
+ * In ungrouped kanban, moves the focus to the next/previous card
+ *
+ * @param {DOMElement} eventTarget the target of the keydown event
+ * @param {string} direction contains either 'LEFT' or 'RIGHT'
+ */
+ _focusOnCardInColumn: function(eventTarget, direction) {
+ var currentColumn = eventTarget.parentElement;
+ var hasSelectedACard = false;
+ var cannotSelectAColumn = false;
+ while (!hasSelectedACard && !cannotSelectAColumn) {
+ var candidateColumn = direction === 'LEFT' ?
+ currentColumn.previousElementSibling :
+ currentColumn.nextElementSibling ;
+ currentColumn = candidateColumn;
+ if (candidateColumn) {
+ var allCardsOfCandidateColumn =
+ candidateColumn.getElementsByClassName('o_kanban_record');
+ if (allCardsOfCandidateColumn.length) {
+ allCardsOfCandidateColumn[0].focus();
+ hasSelectedACard = true;
+ }
+ }
+ else { // either there are no more columns in the direction or
+ // this is not a grouped kanban
+ direction === 'LEFT' ?
+ this._focusOnPreviousCard(eventTarget) :
+ this._focusOnNextCard(eventTarget);
+ cannotSelectAColumn = true;
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onCancelQuickCreate: function () {
+ this._toggleNoContentHelper();
+ },
+ /**
+ * Closes the opened quick create widgets in columns
+ *
+ * @private
+ */
+ _onCloseQuickCreate: function () {
+ if (this.state.groupedBy.length) {
+ _.invoke(this.widgets, 'cancelQuickCreate');
+ }
+ this._toggleNoContentHelper();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onQuickCreateColumnUpdated: function (ev) {
+ ev.stopPropagation();
+ this._toggleNoContentHelper();
+ this._updateExampleBackground();
+ },
+ /**
+ * @private
+ * @param {KeyboardEvent} e
+ */
+ _onRecordKeyDown: function(e) {
+ switch(e.which) {
+ case $.ui.keyCode.DOWN:
+ this._focusOnNextCard(e.currentTarget);
+ e.stopPropagation();
+ e.preventDefault();
+ break;
+ case $.ui.keyCode.UP:
+ const previousFocused = this._focusOnPreviousCard(e.currentTarget);
+ if (!previousFocused) {
+ this.trigger_up('navigation_move', { direction: 'up' });
+ }
+ e.stopPropagation();
+ e.preventDefault();
+ break;
+ case $.ui.keyCode.RIGHT:
+ this._focusOnCardInColumn(e.currentTarget, 'RIGHT');
+ e.stopPropagation();
+ e.preventDefault();
+ break;
+ case $.ui.keyCode.LEFT:
+ this._focusOnCardInColumn(e.currentTarget, 'LEFT');
+ e.stopPropagation();
+ e.preventDefault();
+ break;
+ }
+ },
+ /**
+ * Updates progressbar internal states (necessary for animations) with
+ * received data.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSetProgressBarState: function (ev) {
+ if (!this.columnOptions.progressBarStates[ev.data.columnID]) {
+ this.columnOptions.progressBarStates[ev.data.columnID] = {};
+ }
+ _.extend(this.columnOptions.progressBarStates[ev.data.columnID], ev.data.values);
+ },
+ /**
+ * Closes the opened quick create widgets in columns
+ *
+ * @private
+ */
+ _onStartQuickCreate: function () {
+ this._toggleNoContentHelper(true);
+ },
+ /**
+ * Hide or display the background example:
+ * - displayed when quick create column is display and there is no column else
+ * - hidden otherwise
+ *
+ * @private
+ **/
+ _updateExampleBackground: function () {
+ var $elem = this.$('.o_kanban_example_background_container');
+ if (!this.state.data.length && !$elem.length) {
+ this._renderExampleBackground(this.$el);
+ } else {
+ $elem.remove();
+ }
+ },
+});
+
+return KanbanRenderer;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js
new file mode 100644
index 00000000..1add9169
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_view.js
@@ -0,0 +1,119 @@
+odoo.define('web.KanbanView', function (require) {
+"use strict";
+
+var BasicView = require('web.BasicView');
+var core = require('web.core');
+var KanbanController = require('web.KanbanController');
+var kanbanExamplesRegistry = require('web.kanban_examples_registry');
+var KanbanModel = require('web.KanbanModel');
+var KanbanRenderer = require('web.KanbanRenderer');
+var utils = require('web.utils');
+
+var _lt = core._lt;
+
+var KanbanView = BasicView.extend({
+ accesskey: "k",
+ display_name: _lt("Kanban"),
+ icon: 'fa-th-large',
+ mobile_friendly: true,
+ config: _.extend({}, BasicView.prototype.config, {
+ Model: KanbanModel,
+ Controller: KanbanController,
+ Renderer: KanbanRenderer,
+ }),
+ jsLibs: [],
+ viewType: 'kanban',
+
+ /**
+ * @constructor
+ */
+ init: function (viewInfo, params) {
+ this._super.apply(this, arguments);
+
+ this.loadParams.limit = this.loadParams.limit || 40;
+ this.loadParams.openGroupByDefault = true;
+ this.loadParams.type = 'list';
+ this.noDefaultGroupby = params.noDefaultGroupby;
+ var progressBar;
+ utils.traverse(this.arch, function (n) {
+ var isProgressBar = (n.tag === 'progressbar');
+ if (isProgressBar) {
+ progressBar = _.clone(n.attrs);
+ progressBar.colors = JSON.parse(progressBar.colors);
+ progressBar.sum_field = progressBar.sum_field || false;
+ }
+ return !isProgressBar;
+ });
+ if (progressBar) {
+ this.loadParams.progressBar = progressBar;
+ }
+
+ var activeActions = this.controllerParams.activeActions;
+ var archAttrs = this.arch.attrs;
+ activeActions = _.extend(activeActions, {
+ group_create: this.arch.attrs.group_create ? !!JSON.parse(archAttrs.group_create) : true,
+ group_edit: archAttrs.group_edit ? !!JSON.parse(archAttrs.group_edit) : true,
+ group_delete: archAttrs.group_delete ? !!JSON.parse(archAttrs.group_delete) : true,
+ });
+
+ this.rendererParams.column_options = {
+ editable: activeActions.group_edit,
+ deletable: activeActions.group_delete,
+ archivable: archAttrs.archivable ? !!JSON.parse(archAttrs.archivable) : true,
+ group_creatable: activeActions.group_create,
+ quickCreateView: archAttrs.quick_create_view || null,
+ recordsDraggable: archAttrs.records_draggable ? !!JSON.parse(archAttrs.records_draggable) : true,
+ hasProgressBar: !!progressBar,
+ };
+ this.rendererParams.record_options = {
+ editable: activeActions.edit,
+ deletable: activeActions.delete,
+ read_only_mode: params.readOnlyMode,
+ selectionMode: params.selectionMode,
+ };
+ this.rendererParams.quickCreateEnabled = this._isQuickCreateEnabled();
+ this.rendererParams.readOnlyMode = params.readOnlyMode;
+ var examples = archAttrs.examples;
+ if (examples) {
+ this.rendererParams.examples = kanbanExamplesRegistry.get(examples);
+ }
+
+ this.controllerParams.on_create = archAttrs.on_create;
+ this.controllerParams.hasButtons = !params.selectionMode ? true : false;
+ this.controllerParams.quickCreateEnabled = this.rendererParams.quickCreateEnabled;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} viewInfo
+ * @returns {boolean} true iff the quick create feature is not explicitely
+ * disabled (with create="False" or quick_create="False" in the arch)
+ */
+ _isQuickCreateEnabled: function () {
+ if (!this.controllerParams.activeActions.create) {
+ return false;
+ }
+ if (this.arch.attrs.quick_create !== undefined) {
+ return !!JSON.parse(this.arch.attrs.quick_create);
+ }
+ return true;
+ },
+ /**
+ * @override
+ * @private
+ */
+ _updateMVCParams: function () {
+ this._super.apply(this, arguments);
+ if (this.searchMenuTypes.includes('groupBy') && !this.noDefaultGroupby && this.arch.attrs.default_group_by) {
+ this.loadParams.groupBy = [this.arch.attrs.default_group_by];
+ }
+ },
+});
+
+return KanbanView;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/quick_create_form_view.js b/addons/web/static/src/js/views/kanban/quick_create_form_view.js
new file mode 100644
index 00000000..9286ed82
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/quick_create_form_view.js
@@ -0,0 +1,123 @@
+odoo.define('web.QuickCreateFormView', function (require) {
+"use strict";
+
+/**
+ * This file defines the QuickCreateFormView, an extension of the FormView that
+ * is used by the RecordQuickCreate in Kanban views.
+ */
+
+var BasicModel = require('web.BasicModel');
+var FormController = require('web.FormController');
+var FormRenderer = require('web.FormRenderer');
+var FormView = require('web.FormView');
+const { qweb } = require("web.core");
+
+var QuickCreateFormRenderer = FormRenderer.extend({
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super.apply(this, arguments);
+ this.$el.addClass('o_xxs_form_view');
+ this.$el.removeClass('o_xxl_form_view');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to do nothing so that the renderer won't resize on window resize
+ *
+ * @override
+ */
+ _applyFormSizeClass() {},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNavigationMove: function (ev) {
+ var direction = ev.data.direction;
+ if (direction === 'cancel' || direction === 'next_line') {
+ ev.stopPropagation();
+ this.trigger_up(direction === 'cancel' ? 'cancel' : 'add');
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+});
+
+var QuickCreateFormModel = BasicModel.extend({
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {Object} the changes of the given resource (server commands for
+ * x2manys)
+ */
+ getChanges: function (localID) {
+ var record = this.localData[localID];
+ return this._generateChanges(record, {changesOnly: false});
+ },
+});
+
+var QuickCreateFormController = FormController.extend({
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Asks all field widgets to notify the environment with their current value
+ * (useful for instance for input fields that still have the focus and that
+ * could have not notified the environment of their changes yet).
+ * Synchronizes with the controller's mutex in case there would already be
+ * pending changes being applied.
+ *
+ * @return {Promise}
+ */
+ commitChanges: function () {
+ var mutexDef = this.mutex.getUnlockedDef();
+ return Promise.all([mutexDef, this.renderer.commitChanges(this.handle)]);
+ },
+ /**
+ * @returns {Object} the changes done on the current record
+ */
+ getChanges: function () {
+ return this.model.getChanges(this.handle);
+ },
+
+ /**
+ * @override
+ */
+ renderButtons($node) {
+ this.$buttons = $(qweb.render('KanbanView.RecordQuickCreate.buttons'));
+ if ($node) {
+ this.$buttons.appendTo($node);
+ }
+ },
+
+ /**
+ * @override
+ */
+ updateButtons() {/* No need to update the buttons */},
+});
+
+var QuickCreateFormView = FormView.extend({
+ withControlPanel: false,
+ config: _.extend({}, FormView.prototype.config, {
+ Model: QuickCreateFormModel,
+ Renderer: QuickCreateFormRenderer,
+ Controller: QuickCreateFormController,
+ }),
+});
+
+return QuickCreateFormView;
+
+});
diff --git a/addons/web/static/src/js/views/list/list_confirm_dialog.js b/addons/web/static/src/js/views/list/list_confirm_dialog.js
new file mode 100644
index 00000000..7fba13c3
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_confirm_dialog.js
@@ -0,0 +1,104 @@
+odoo.define('web.ListConfirmDialog', function (require) {
+"use strict";
+
+const core = require('web.core');
+const Dialog = require('web.Dialog');
+const FieldWrapper = require('web.FieldWrapper');
+const { WidgetAdapterMixin } = require('web.OwlCompatibility');
+const utils = require('web.utils');
+
+const _t = core._t;
+const qweb = core.qweb;
+
+/**
+ * Multi edition confirmation modal for list views.
+ *
+ * Handles the display of the amount of changed records (+ valid ones) and
+ * of the widget representing the new value.
+ *
+ * @class
+ */
+const ListConfirmDialog = Dialog.extend(WidgetAdapterMixin, {
+ /**
+ * @constructor
+ * @override
+ * @param {Widget} parent
+ * @param {Object} record edited record with updated value
+ * @param {Object} changes changes registered by the list controller
+ * @param {Object} changes isDomainSelected true iff the user selected the
+ * whole domain
+ * @param {string} changes.fieldLabel label of the changed field
+ * @param {string} changes.fieldName technical name of the changed field
+ * @param {number} changes.nbRecords number of records (total)
+ * @param {number} changes.nbValidRecords number of valid records
+ * @param {Object} [options]
+ */
+ init: function (parent, record, changes, options) {
+ options = Object.assign({}, options, {
+ $content: $(qweb.render('ListView.confirmModal', { changes })),
+ buttons: options.buttons || [{
+ text: _t("Ok"),
+ classes: 'btn-primary',
+ close: true,
+ click: options.confirm_callback,
+ }, {
+ text: _t("Cancel"),
+ close: true,
+ click: options.cancel_callback,
+ }],
+ onForceClose: options.cancel_callback,
+ size: options.size || 'medium',
+ title: options.title || _t("Confirmation"),
+ });
+
+ this._super(parent, options);
+
+ const Widget = record.fieldsInfo.list[changes.fieldName].Widget;
+ const widgetOptions = {
+ mode: 'readonly',
+ viewType: 'list',
+ noOpen: true,
+ };
+ this.isLegacyWidget = !utils.isComponent(Widget);
+ if (this.isLegacyWidget) {
+ this.fieldWidget = new Widget(this, changes.fieldName, record, widgetOptions);
+ } else {
+ this.fieldWidget = new FieldWrapper(this, Widget, {
+ fieldName: changes.fieldName,
+ record,
+ options: widgetOptions,
+ });
+ }
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ let widgetProm;
+ if (this.isLegacyWidget) {
+ widgetProm = this.fieldWidget._widgetRenderAndInsert(function () {});
+ } else {
+ widgetProm = this.fieldWidget.mount(document.createDocumentFragment());
+ }
+ return Promise.all([widgetProm, this._super.apply(this, arguments)]);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$content.find('.o_changes_widget').replaceWith(this.fieldWidget.$el);
+ this.fieldWidget.el.style.pointerEvents = 'none';
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ WidgetAdapterMixin.destroy.call(this);
+ this._super();
+ },
+});
+
+return ListConfirmDialog;
+
+});
diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js
new file mode 100644
index 00000000..a19afb6a
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -0,0 +1,992 @@
+odoo.define('web.ListController', function (require) {
+"use strict";
+
+/**
+ * The List Controller controls the list renderer and the list model. Its role
+ * is to allow these two components to communicate properly, and also, to render
+ * and bind all extra buttons/pager in the control panel.
+ */
+
+var core = require('web.core');
+var BasicController = require('web.BasicController');
+var DataExport = require('web.DataExport');
+var Dialog = require('web.Dialog');
+var ListConfirmDialog = require('web.ListConfirmDialog');
+var session = require('web.session');
+const viewUtils = require('web.viewUtils');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var ListController = BasicController.extend({
+ /**
+ * This key contains the name of the buttons template to render on top of
+ * the list view. It can be overridden to add buttons in specific child views.
+ */
+ buttons_template: 'ListView.buttons',
+ events: _.extend({}, BasicController.prototype.events, {
+ 'click .o_list_export_xlsx': '_onDirectExportData',
+ 'click .o_list_select_domain': '_onSelectDomain',
+ }),
+ custom_events: _.extend({}, BasicController.prototype.custom_events, {
+ activate_next_widget: '_onActivateNextWidget',
+ add_record: '_onAddRecord',
+ button_clicked: '_onButtonClicked',
+ group_edit_button_clicked: '_onEditGroupClicked',
+ edit_line: '_onEditLine',
+ save_line: '_onSaveLine',
+ selection_changed: '_onSelectionChanged',
+ toggle_column_order: '_onToggleColumnOrder',
+ toggle_group: '_onToggleGroup',
+ }),
+ /**
+ * @constructor
+ * @override
+ * @param {Object} params
+ * @param {boolean} params.editable
+ * @param {boolean} params.hasActionMenus
+ * @param {Object[]} [params.headerButtons=[]]: a list of node descriptors
+ * for controlPanel's action buttons
+ * @param {Object} params.toolbarActions
+ * @param {boolean} params.noLeaf
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.hasActionMenus = params.hasActionMenus;
+ this.headerButtons = params.headerButtons || [];
+ this.toolbarActions = params.toolbarActions || {};
+ this.editable = params.editable;
+ this.noLeaf = params.noLeaf;
+ this.selectedRecords = params.selectedRecords || [];
+ this.multipleRecordsSavingPromise = null;
+ this.fieldChangedPrevented = false;
+ this.lastFieldChangedEvent = null;
+ this.isPageSelected = false; // true iff all records of the page are selected
+ this.isDomainSelected = false; // true iff the user selected all records matching the domain
+ this.isExportEnable = false;
+ },
+
+ willStart() {
+ const sup = this._super(...arguments);
+ const acl = session.user_has_group('base.group_allow_export').then(hasGroup => {
+ this.isExportEnable = hasGroup;
+ });
+ return Promise.all([sup, acl]);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /*
+ * @override
+ */
+ getOwnedQueryParams: function () {
+ var state = this._super.apply(this, arguments);
+ var orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || [];
+ return _.extend({}, state, {orderedBy: orderedBy});
+ },
+ /**
+ * Returns the list of currently selected res_ids (with the check boxes on
+ * the left)
+ *
+ * @override
+ *
+ * @returns {number[]} list of res_ids
+ */
+ getSelectedIds: function () {
+ return _.map(this.getSelectedRecords(), function (record) {
+ return record.res_id;
+ });
+ },
+ /**
+ * Returns the list of currently selected records (with the check boxes on
+ * the left)
+ *
+ * @returns {Object[]} list of records
+ */
+ getSelectedRecords: function () {
+ var self = this;
+ return _.map(this.selectedRecords, function (db_id) {
+ return self.model.get(db_id, {raw: true});
+ });
+ },
+ /**
+ * Display and bind all buttons in the control panel
+ *
+ * Note: clicking on the "Save" button does nothing special. Indeed, all
+ * editable rows are saved once left and clicking on the "Save" button does
+ * induce the leaving of the current row.
+ *
+ * @override
+ */
+ renderButtons: function ($node) {
+ if (this.noLeaf || !this.hasButtons) {
+ this.hasButtons = false;
+ this.$buttons = $('<div>');
+ } else {
+ this.$buttons = $(qweb.render(this.buttons_template, {widget: this}));
+ this.$buttons.on('click', '.o_list_button_add', this._onCreateRecord.bind(this));
+ this._assignCreateKeyboardBehavior(this.$buttons.find('.o_list_button_add'));
+ this.$buttons.find('.o_list_button_add').tooltip({
+ delay: {show: 200, hide: 0},
+ title: function () {
+ return qweb.render('CreateButton.tooltip');
+ },
+ trigger: 'manual',
+ });
+ this.$buttons.on('mousedown', '.o_list_button_discard', this._onDiscardMousedown.bind(this));
+ this.$buttons.on('click', '.o_list_button_discard', this._onDiscard.bind(this));
+ }
+ if ($node) {
+ this.$buttons.appendTo($node);
+ }
+ },
+ /**
+ * Renders (and updates) the buttons that are described inside the `header`
+ * node of the list view arch. Those buttons are visible when selecting some
+ * records. They will be appended to the controlPanel's buttons.
+ *
+ * @private
+ */
+ _renderHeaderButtons() {
+ if (this.$headerButtons) {
+ this.$headerButtons.remove();
+ this.$headerButtons = null;
+ }
+ if (!this.headerButtons.length || !this.selectedRecords.length) {
+ return;
+ }
+ const btnClasses = 'btn-primary btn-secondary btn-link btn-success btn-info btn-warning btn-danger'.split(' ');
+ let $elms = $();
+ this.headerButtons.forEach(node => {
+ const $btn = viewUtils.renderButtonFromNode(node);
+ $btn.addClass('btn');
+ if (!btnClasses.some(cls => $btn.hasClass(cls))) {
+ $btn.addClass('btn-secondary');
+ }
+ $btn.on("click", this._onHeaderButtonClicked.bind(this, node));
+ $elms = $elms.add($btn);
+ });
+ this.$headerButtons = $elms;
+ this.$headerButtons.appendTo(this.$buttons);
+ },
+ /**
+ * Overrides to update the list of selected records
+ *
+ * @override
+ */
+ update: function (params, options) {
+ var self = this;
+ let res_ids;
+ if (options && options.keepSelection) {
+ // filter out removed records from selection
+ res_ids = this.model.get(this.handle).res_ids;
+ this.selectedRecords = _.filter(this.selectedRecords, function (id) {
+ return _.contains(res_ids, self.model.get(id).res_id);
+ });
+ } else {
+ this.selectedRecords = [];
+ }
+ if (this.selectedRecords.length === 0 || this.selectedRecords.length < res_ids.length) {
+ this.isDomainSelected = false;
+ this.isPageSelected = false;
+ }
+
+ params.selectedRecords = this.selectedRecords;
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * This helper simply makes sure that the control panel buttons matches the
+ * current mode.
+ *
+ * @override
+ * @param {string} mode either 'readonly' or 'edit'
+ */
+ updateButtons: function (mode) {
+ if (this.hasButtons) {
+ this.$buttons.toggleClass('o-editing', mode === 'edit');
+ const state = this.model.get(this.handle, {raw: true});
+ if (state.count) {
+ this.$buttons.find('.o_list_export_xlsx').show();
+ } else {
+ this.$buttons.find('.o_list_export_xlsx').hide();
+ }
+ }
+ this._updateSelectionBox();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see BasicController._abandonRecord
+ * If the given abandoned record is not the main one, notifies the renderer
+ * to remove the appropriate subrecord (line).
+ *
+ * @override
+ * @private
+ * @param {string} [recordID] - default to the main recordID
+ */
+ _abandonRecord: function (recordID) {
+ this._super.apply(this, arguments);
+ if ((recordID || this.handle) !== this.handle) {
+ var state = this.model.get(this.handle);
+ this.renderer.removeLine(state, recordID);
+ this._updatePaging(state);
+ }
+ },
+ /**
+ * Adds a new record to the a dataPoint of type 'list'.
+ * Disables the buttons to prevent concurrent record creation or edition.
+ *
+ * @todo make record creation a basic controller feature
+ * @private
+ * @param {string} dataPointId a dataPoint of type 'list' (may be grouped)
+ * @return {Promise}
+ */
+ _addRecord: function (dataPointId) {
+ var self = this;
+ this._disableButtons();
+ return this._removeSampleData(() => {
+ return this.renderer.unselectRow().then(function () {
+ return self.model.addDefaultRecord(dataPointId, {
+ position: self.editable,
+ });
+ }).then(function (recordID) {
+ var state = self.model.get(self.handle);
+ self._updateRendererState(state, { keepWidths: true })
+ .then(function () {
+ self.renderer.editRecord(recordID);
+ })
+ .then(() => {
+ self._updatePaging(state);
+ });
+ }).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this));
+ });
+ },
+ /**
+ * Assign on the buttons create additionnal behavior to facilitate the work of the users doing input only using the keyboard
+ *
+ * @param {jQueryElement} $createButton The create button itself
+ */
+ _assignCreateKeyboardBehavior: function($createButton) {
+ var self = this;
+ $createButton.on('keydown', function(e) {
+ $createButton.tooltip('hide');
+ switch(e.which) {
+ case $.ui.keyCode.ENTER:
+ e.preventDefault();
+ self._onCreateRecord.apply(self);
+ break;
+ case $.ui.keyCode.DOWN:
+ e.preventDefault();
+ self._giveFocus();
+ break;
+ case $.ui.keyCode.TAB:
+ if (
+ !e.shiftKey &&
+ e.target.classList.contains("btn-primary") &&
+ !self.model.isInSampleMode()
+ ) {
+ e.preventDefault();
+ $createButton.tooltip('show');
+ }
+ break;
+ }
+ });
+ },
+ /**
+ * This function is the hook called by the field manager mixin to confirm
+ * that a record has been saved.
+ *
+ * @override
+ * @param {string} id a basicmodel valid resource handle. It is supposed to
+ * be a record from the list view.
+ * @returns {Promise}
+ */
+ _confirmSave: function (id) {
+ var state = this.model.get(this.handle);
+ return this._updateRendererState(state, { noRender: true })
+ .then(this._setMode.bind(this, 'readonly', id));
+ },
+ /**
+ * Deletes records matching the current domain. We limit the number of
+ * deleted records to the 'active_ids_limit' config parameter.
+ *
+ * @private
+ */
+ _deleteRecordsInCurrentDomain: function () {
+ const doIt = async () => {
+ const state = this.model.get(this.handle, {raw: true});
+ const resIds = await this._domainToResIds(state.getDomain(), session.active_ids_limit);
+ await this._rpc({
+ model: this.modelName,
+ method: 'unlink',
+ args: [resIds],
+ context: state.getContext(),
+ });
+ if (resIds.length === session.active_ids_limit) {
+ const msg = _.str.sprintf(
+ _t("Only the first %d records have been deleted (out of %d selected)"),
+ resIds.length, state.count
+ );
+ this.do_notify(false, msg);
+ }
+ this.reload();
+ };
+ if (this.confirmOnDelete) {
+ Dialog.confirm(this, _t("Are you sure you want to delete these records ?"), {
+ confirm_callback: doIt,
+ });
+ } else {
+ doIt();
+ }
+ },
+ /**
+ * To improve performance, list view must not be rerendered if it is asked
+ * to discard all its changes. Indeed, only the in-edition row needs to be
+ * discarded in that case.
+ *
+ * @override
+ * @private
+ * @param {string} [recordID] - default to main recordID
+ * @returns {Promise}
+ */
+ _discardChanges: function (recordID) {
+ if ((recordID || this.handle) === this.handle) {
+ recordID = this.renderer.getEditableRecordID();
+ if (recordID === null) {
+ return Promise.resolve();
+ }
+ }
+ var self = this;
+ return this._super(recordID).then(function () {
+ self.updateButtons('readonly');
+ });
+ },
+ /**
+ * Returns the ids of records matching the given domain.
+ *
+ * @private
+ * @param {Array[]} domain
+ * @param {integer} [limit]
+ * @returns {integer[]}
+ */
+ _domainToResIds: function (domain, limit) {
+ return this._rpc({
+ model: this.modelName,
+ method: 'search',
+ args: [domain],
+ kwargs: {
+ limit: limit,
+ },
+ });
+ },
+ /**
+ * @returns {DataExport} the export dialog widget
+ * @private
+ */
+ _getExportDialogWidget() {
+ let state = this.model.get(this.handle);
+ let defaultExportFields = this.renderer.columns.filter(field => field.tag === 'field' && state.fields[field.attrs.name].exportable !== false).map(field => field.attrs.name);
+ let groupedBy = this.renderer.state.groupedBy;
+ const domain = this.isDomainSelected && state.getDomain();
+ return new DataExport(this, state, defaultExportFields, groupedBy,
+ domain, this.getSelectedIds());
+ },
+ /**
+ * Only display the pager when there are data to display.
+ *
+ * @override
+ * @private
+ */
+ _getPagingInfo: function (state) {
+ if (!state.count) {
+ return null;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ * @private
+ */
+ _getActionMenuItems: function (state) {
+ if (!this.hasActionMenus || !this.selectedRecords.length) {
+ return null;
+ }
+ const props = this._super(...arguments);
+ const otherActionItems = [];
+ if (this.isExportEnable) {
+ otherActionItems.push({
+ description: _t("Export"),
+ callback: () => this._onExportData()
+ });
+ }
+ if (this.archiveEnabled) {
+ otherActionItems.push({
+ description: _t("Archive"),
+ callback: () => {
+ Dialog.confirm(this, _t("Are you sure that you want to archive all the selected records?"), {
+ confirm_callback: () => this._toggleArchiveState(true),
+ });
+ }
+ }, {
+ description: _t("Unarchive"),
+ callback: () => this._toggleArchiveState(false)
+ });
+ }
+ if (this.activeActions.delete) {
+ otherActionItems.push({
+ description: _t("Delete"),
+ callback: () => this._onDeleteSelectedRecords()
+ });
+ }
+ return Object.assign(props, {
+ items: Object.assign({}, this.toolbarActions, { other: otherActionItems }),
+ context: state.getContext(),
+ domain: state.getDomain(),
+ isDomainSelected: this.isDomainSelected,
+ });
+ },
+ /**
+ * Saves multiple records at once. This method is called by the _onFieldChanged method
+ * since the record must be confirmed as soon as the focus leaves a dirty cell.
+ * Pseudo-validation is performed with registered modifiers.
+ * Returns a promise that is resolved when confirming and rejected in any other case.
+ *
+ * @private
+ * @param {string} recordId
+ * @param {Object} node
+ * @param {Object} changes
+ * @returns {Promise}
+ */
+ _saveMultipleRecords: function (recordId, node, changes) {
+ var fieldName = Object.keys(changes)[0];
+ var value = Object.values(changes)[0];
+ var recordIds = _.union([recordId], this.selectedRecords);
+ var validRecordIds = recordIds.reduce((result, nextRecordId) => {
+ var record = this.model.get(nextRecordId);
+ var modifiers = this.renderer._registerModifiers(node, record);
+ if (!modifiers.readonly && (!modifiers.required || value)) {
+ result.push(nextRecordId);
+ }
+ return result;
+ }, []);
+ return new Promise((resolve, reject) => {
+ const saveRecords = () => {
+ this.model.saveRecords(this.handle, recordId, validRecordIds, fieldName)
+ .then(async () => {
+ this.updateButtons('readonly');
+ const state = this.model.get(this.handle);
+ // We need to check the current multi-editable state here
+ // in case the selection is changed. If there are changes
+ // and the list was multi-editable, we do not want to select
+ // the next row.
+ this.selectedRecords = [];
+ await this._updateRendererState(state, {
+ keepWidths: true,
+ selectedRecords: [],
+ });
+ this._updateSelectionBox();
+ this.renderer.focusCell(recordId, node);
+ resolve(!Object.keys(changes).length);
+ })
+ .guardedCatch(discardAndReject);
+ };
+ const discardAndReject = () => {
+ this.model.discardChanges(recordId);
+ this._confirmSave(recordId).then(() => {
+ this.renderer.focusCell(recordId, node);
+ reject();
+ });
+ };
+ if (validRecordIds.length > 0) {
+ if (recordIds.length === 1) {
+ // Save without prompt
+ return saveRecords();
+ }
+ const dialogOptions = {
+ confirm_callback: saveRecords,
+ cancel_callback: discardAndReject,
+ };
+ const record = this.model.get(recordId);
+ const dialogChanges = {
+ isDomainSelected: this.isDomainSelected,
+ fieldLabel: node.attrs.string || record.fields[fieldName].string,
+ fieldName: node.attrs.name,
+ nbRecords: recordIds.length,
+ nbValidRecords: validRecordIds.length,
+ };
+ new ListConfirmDialog(this, record, dialogChanges, dialogOptions)
+ .open({ shouldFocusButtons: true });
+ } else {
+ Dialog.alert(this, _t("No valid record to save"), {
+ confirm_callback: discardAndReject,
+ });
+ }
+ });
+ },
+ /**
+ * Overridden to deal with edition of multiple line.
+ *
+ * @override
+ * @param {string} recordId
+ */
+ _saveRecord: function (recordId) {
+ var record = this.model.get(recordId, { raw: true });
+ if (record.isDirty() && this.renderer.isInMultipleRecordEdition(recordId)) {
+ if (!this.multipleRecordsSavingPromise && this.lastFieldChangedEvent) {
+ this._onFieldChanged(this.lastFieldChangedEvent);
+ this.lastFieldChangedEvent = null;
+ }
+ // do not save the record (see _saveMultipleRecords)
+ const prom = this.multipleRecordsSavingPromise || Promise.reject();
+ this.multipleRecordsSavingPromise = null;
+ return prom;
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Allows to change the mode of a single row.
+ *
+ * @override
+ * @private
+ * @param {string} mode
+ * @param {string} [recordID] - default to main recordID
+ * @returns {Promise}
+ */
+ _setMode: function (mode, recordID) {
+ if ((recordID || this.handle) !== this.handle) {
+ this.mode = mode;
+ this.updateButtons(mode);
+ return this.renderer.setRowMode(recordID, mode);
+ } else {
+ return this._super.apply(this, arguments);
+ }
+ },
+ /**
+ * @override
+ */
+ _shouldBounceOnClick() {
+ const state = this.model.get(this.handle, {raw: true});
+ return !state.count || state.isSample;
+ },
+ /**
+ * Called when clicking on 'Archive' or 'Unarchive' in the sidebar.
+ *
+ * @private
+ * @param {boolean} archive
+ * @returns {Promise}
+ */
+ _toggleArchiveState: async function (archive) {
+ let resIds;
+ let displayNotif = false;
+ const state = this.model.get(this.handle, {raw: true});
+ if (this.isDomainSelected) {
+ resIds = await this._domainToResIds(state.getDomain(), session.active_ids_limit);
+ displayNotif = (resIds.length === session.active_ids_limit);
+ } else {
+ resIds = this.model.localIdsToResIds(this.selectedRecords);
+ }
+ await this._archive(resIds, archive);
+ if (displayNotif) {
+ const msg = _.str.sprintf(
+ _t("Of the %d records selected, only the first %d have been archived/unarchived."),
+ state.count, resIds.length
+ );
+ this.do_notify(_t('Warning'), msg);
+ }
+ },
+ /**
+ * Hide the create button in non-empty grouped editable list views, as an
+ * 'Add an item' link is available in each group.
+ *
+ * @private
+ */
+ _toggleCreateButton: function () {
+ if (this.hasButtons) {
+ var state = this.model.get(this.handle);
+ var createHidden = this.editable && state.groupedBy.length && state.data.length;
+ this.$buttons.find('.o_list_button_add').toggleClass('o_hidden', !!createHidden);
+ }
+ },
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ _update: async function () {
+ await this._super(...arguments);
+ this._toggleCreateButton();
+ this.updateButtons('readonly');
+ },
+ /**
+ * When records are selected, a box is displayed in the control panel (next
+ * to the buttons). It indicates the number of selected records, and allows
+ * the user to select the whole domain instead of the current page (when the
+ * page is selected). This function renders and displays this box when at
+ * least one record is selected.
+ * Since header action buttons' display is dependent on the selection, we
+ * refresh them each time the selection is updated.
+ *
+ * @private
+ */
+ _updateSelectionBox() {
+ if (this.$selectionBox) {
+ this.$selectionBox.remove();
+ this.$selectionBox = null;
+ }
+ if (this.selectedRecords.length) {
+ const state = this.model.get(this.handle, {raw: true});
+ this.$selectionBox = $(qweb.render('ListView.selection', {
+ isDomainSelected: this.isDomainSelected,
+ isPageSelected: this.isPageSelected,
+ nbSelected: this.selectedRecords.length,
+ nbTotal: state.count,
+ }));
+ this.$selectionBox.appendTo(this.$buttons);
+ }
+ this._renderHeaderButtons();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Triggered when navigating with TAB, when the end of the list has been
+ * reached. Go back to the first row in that case.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onActivateNextWidget: function (ev) {
+ ev.stopPropagation();
+ this.renderer.editFirstRecord(ev);
+ },
+ /**
+ * Add a record to the list
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {string} [ev.data.groupId=this.handle] the id of a dataPoint of
+ * type list to which the record must be added (default: main list)
+ */
+ _onAddRecord: function (ev) {
+ ev.stopPropagation();
+ var dataPointId = ev.data.groupId || this.handle;
+ if (this.activeActions.create) {
+ this._addRecord(dataPointId);
+ } else if (ev.data.onFail) {
+ ev.data.onFail();
+ }
+ },
+ /**
+ * Handles a click on a button by performing its action.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onButtonClicked: function (ev) {
+ ev.stopPropagation();
+ this._callButtonAction(ev.data.attrs, ev.data.record);
+ },
+ /**
+ * When the user clicks on the 'create' button, two things can happen. We
+ * can switch to the form view with no active res_id, so it is in 'create'
+ * mode, or we can edit inline.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onCreateRecord: function (ev) {
+ // we prevent the event propagation because we don't want this event to
+ // trigger a click on the main bus, which would be then caught by the
+ // list editable renderer and would unselect the newly created row
+ if (ev) {
+ ev.stopPropagation();
+ }
+ var state = this.model.get(this.handle, {raw: true});
+ if (this.editable && !state.groupedBy.length) {
+ this._addRecord(this.handle);
+ } else {
+ this.trigger_up('switch_view', {view_type: 'form', res_id: undefined});
+ }
+ },
+ /**
+ * Called when the 'delete' action is clicked on in the side bar.
+ *
+ * @private
+ */
+ _onDeleteSelectedRecords: async function () {
+ if (this.isDomainSelected) {
+ this._deleteRecordsInCurrentDomain();
+ } else {
+ this._deleteRecords(this.selectedRecords);
+ }
+ },
+ /**
+ * Handler called when the user clicked on the 'Discard' button.
+ *
+ * @param {Event} ev
+ */
+ _onDiscard: function (ev) {
+ ev.stopPropagation(); // So that it is not considered as a row leaving
+ this._discardChanges().then(() => {
+ this.lastFieldChangedEvent = null;
+ });
+ },
+ /**
+ * Used to detect if the discard button is about to be clicked.
+ * Some focusout events might occur and trigger a save which
+ * is not always wanted when clicking "Discard".
+ *
+ * @param {MouseEvent} ev
+ * @private
+ */
+ _onDiscardMousedown: function (ev) {
+ var self = this;
+ this.fieldChangedPrevented = true;
+ window.addEventListener('mouseup', function (mouseupEvent) {
+ var preventedEvent = self.fieldChangedPrevented;
+ self.fieldChangedPrevented = false;
+ // If the user starts clicking (mousedown) on the button and stops clicking
+ // (mouseup) outside of the button, we want to trigger the original onFieldChanged
+ // Event that was prevented in the meantime.
+ if (ev.target !== mouseupEvent.target && preventedEvent.constructor.name === 'OdooEvent') {
+ self._onFieldChanged(preventedEvent);
+ }
+ }, { capture: true, once: true });
+ },
+ /**
+ * Called when the user asks to edit a row -> Updates the controller buttons
+ *
+ * @param {OdooEvent} ev
+ */
+ _onEditLine: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ this.trigger_up('mutexify', {
+ action: function () {
+ self._setMode('edit', ev.data.recordId)
+ .then(ev.data.onSuccess);
+ },
+ });
+ },
+ /**
+ * Opens the Export Dialog
+ *
+ * @private
+ */
+ _onExportData: function () {
+ this._getExportDialogWidget().open();
+ },
+ /**
+ * Export Records in a xls file
+ *
+ * @private
+ */
+ _onDirectExportData() {
+ // access rights check before exporting data
+ return this._rpc({
+ model: 'ir.exports',
+ method: 'search_read',
+ args: [[], ['id']],
+ limit: 1,
+ }).then(() => this._getExportDialogWidget().export())
+ },
+ /**
+ * Opens the related form view.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onEditGroupClicked: function (ev) {
+ ev.stopPropagation();
+ this.do_action({
+ context: {create: false},
+ type: 'ir.actions.act_window',
+ views: [[false, 'form']],
+ res_model: ev.data.record.model,
+ res_id: ev.data.record.res_id,
+ flags: {mode: 'edit'},
+ });
+ },
+ /**
+ * Overridden to deal with the edition of multiple records.
+ *
+ * Note that we don't manage saving multiple records on saveLine
+ * because we don't want the onchanges to be applied.
+ *
+ * @private
+ * @override
+ */
+ _onFieldChanged: function (ev) {
+ ev.stopPropagation();
+ const recordId = ev.data.dataPointID;
+ this.lastFieldChangedEvent = ev;
+
+ if (this.fieldChangedPrevented) {
+ this.fieldChangedPrevented = ev;
+ } else if (this.renderer.isInMultipleRecordEdition(recordId)) {
+ const saveMulti = () => {
+ // if ev.data.__originalComponent is set, it is the field Component
+ // that triggered the event, otherwise ev.target is the legacy field
+ // Widget that triggered the event
+ const target = ev.data.__originalComponent || ev.target;
+ this.multipleRecordsSavingPromise =
+ this._saveMultipleRecords(ev.data.dataPointID, target.__node, ev.data.changes);
+ };
+ // deal with edition of multiple lines
+ ev.data.onSuccess = saveMulti; // will ask confirmation, and save
+ ev.data.onFailure = saveMulti; // will show the appropriate dialog
+ // disable onchanges as we'll save directly
+ ev.data.notifyChange = false;
+ // In multi edit mode, we will be asked if we want to write on the selected
+ // records, so the force_save for readonly is not necessary.
+ ev.data.force_save = false;
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @private
+ * @param {Object} node the button's node in the xml
+ * @returns {Promise}
+ */
+ async _onHeaderButtonClicked(node) {
+ this._disableButtons();
+ const state = this.model.get(this.handle);
+ try {
+ let resIds;
+ if (this.isDomainSelected) {
+ const limit = session.active_ids_limit;
+ resIds = await this._domainToResIds(state.getDomain(), limit);
+ } else {
+ resIds = this.getSelectedIds();
+ }
+ // add the context of the button node (in the xml) and our custom one
+ // (active_ids and domain) to the action's execution context
+ const actionData = Object.assign({}, node.attrs, {
+ context: state.getContext({ additionalContext: node.attrs.context }),
+ });
+ Object.assign(actionData.context, {
+ active_domain: state.getDomain(),
+ active_id: resIds[0],
+ active_ids: resIds,
+ active_model: state.model,
+ });
+ // load the action with the correct context and record parameters (resIDs, model etc...)
+ const recordData = {
+ context: state.getContext(),
+ model: state.model,
+ resIDs: resIds,
+ };
+ await this._executeButtonAction(actionData, recordData);
+ } finally {
+ this._enableButtons();
+ }
+ },
+ /**
+ * Called when the renderer displays an editable row and the user tries to
+ * leave it -> Saves the record associated to that line.
+ *
+ * @param {OdooEvent} ev
+ */
+ _onSaveLine: function (ev) {
+ this.saveRecord(ev.data.recordID)
+ .then(ev.data.onSuccess)
+ .guardedCatch(ev.data.onFailure);
+ },
+ /**
+ * @private
+ */
+ _onSelectDomain: function (ev) {
+ ev.preventDefault();
+ this.isDomainSelected = true;
+ this._updateSelectionBox();
+ this._updateControlPanel();
+ },
+ /**
+ * When the current selection changes (by clicking on the checkboxes on the
+ * left), we need to display (or hide) the 'sidebar'.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSelectionChanged: function (ev) {
+ this.selectedRecords = ev.data.selection;
+ this.isPageSelected = ev.data.allChecked;
+ this.isDomainSelected = false;
+ this.$('.o_list_export_xlsx').toggle(!this.selectedRecords.length);
+ this._updateSelectionBox();
+ this._updateControlPanel();
+ },
+ /**
+ * If the record is set as dirty while in multiple record edition,
+ * we want to immediatly discard the change.
+ *
+ * @private
+ * @override
+ * @param {OdooEvent} ev
+ */
+ _onSetDirty: function (ev) {
+ var recordId = ev.data.dataPointID;
+ if (this.renderer.isInMultipleRecordEdition(recordId)) {
+ ev.stopPropagation();
+ Dialog.alert(this, _t("No valid record to save"), {
+ confirm_callback: async () => {
+ this.model.discardChanges(recordId);
+ await this._confirmSave(recordId);
+ this.renderer.focusCell(recordId, ev.target.__node);
+ },
+ });
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+ /**
+ * When the user clicks on one of the sortable column headers, we need to
+ * tell the model to sort itself properly, to update the pager and to
+ * rerender the view.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onToggleColumnOrder: function (ev) {
+ ev.stopPropagation();
+ var state = this.model.get(this.handle);
+ if (!state.groupedBy) {
+ this._updatePaging(state, { currentMinimum: 1 });
+ }
+ var self = this;
+ this.model.setSort(state.id, ev.data.name).then(function () {
+ self.update({});
+ });
+ },
+ /**
+ * In a grouped list view, each group can be clicked on to open/close them.
+ * This method just transfer the request to the model, then update the
+ * renderer.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onToggleGroup: function (ev) {
+ ev.stopPropagation();
+ var self = this;
+ this.model
+ .toggleGroup(ev.data.group.id)
+ .then(function () {
+ self.update({}, {keepSelection: true, reload: false}).then(function () {
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
+ }
+ });
+ });
+ },
+});
+
+return ListController;
+
+});
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
new file mode 100644
index 00000000..7afe0425
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -0,0 +1,1851 @@
+odoo.define('web.EditableListRenderer', function (require) {
+"use strict";
+
+/**
+ * Editable List renderer
+ *
+ * The list renderer is reasonably complex, so we split it in two files. This
+ * file simply 'includes' the basic ListRenderer to add all the necessary
+ * behaviors to enable editing records.
+ *
+ * Unlike Odoo v10 and before, this list renderer is independant from the form
+ * view. It uses the same widgets, but the code is totally stand alone.
+ */
+var core = require('web.core');
+var dom = require('web.dom');
+var ListRenderer = require('web.ListRenderer');
+var utils = require('web.utils');
+const { WidgetAdapterMixin } = require('web.OwlCompatibility');
+
+var _t = core._t;
+
+ListRenderer.include({
+ RESIZE_DELAY: 200,
+ custom_events: _.extend({}, ListRenderer.prototype.custom_events, {
+ navigation_move: '_onNavigationMove',
+ }),
+ events: _.extend({}, ListRenderer.prototype.events, {
+ 'click .o_field_x2many_list_row_add a': '_onAddRecord',
+ 'click .o_group_field_row_add a': '_onAddRecordToGroup',
+ 'keydown .o_field_x2many_list_row_add a': '_onKeyDownAddRecord',
+ 'click tbody td.o_data_cell': '_onCellClick',
+ 'click tbody tr:not(.o_data_row)': '_onEmptyRowClick',
+ 'click tfoot': '_onFooterClick',
+ 'click tr .o_list_record_remove': '_onRemoveIconClick',
+ }),
+ /**
+ * @override
+ * @param {Object} params
+ * @param {boolean} params.addCreateLine
+ * @param {boolean} params.addCreateLineInGroups
+ * @param {boolean} params.addTrashIcon
+ * @param {boolean} params.isMany2Many
+ * @param {boolean} params.isMultiEditable
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+
+ this.editable = params.editable;
+ this.isMultiEditable = params.isMultiEditable;
+ this.columnWidths = false;
+
+ // if addCreateLine (resp. addCreateLineInGroups) is true, the renderer
+ // will add a 'Add a line' link at the bottom of the list view (resp.
+ // at the bottom of each group)
+ this.addCreateLine = params.addCreateLine;
+ this.addCreateLineInGroups = params.addCreateLineInGroups;
+
+ // Controls allow overriding "add a line" by custom controls.
+
+ // Each <control> (only one is actually needed) is a container for (multiple) <create>.
+ // Each <create> will be a "add a line" button with custom text and context.
+
+ // The following code will browse the arch to find
+ // all the <create> that are inside <control>
+ this.creates = [];
+ this.arch.children.forEach(child => {
+ if (child.tag !== 'control') {
+ return;
+ }
+ child.children.forEach(child => {
+ if (child.tag !== 'create' || child.attrs.invisible) {
+ return;
+ }
+ this.creates.push({
+ context: child.attrs.context,
+ string: child.attrs.string,
+ });
+ });
+ });
+
+ // Add the default button if we didn't find any custom button.
+ if (this.creates.length === 0) {
+ this.creates.push({
+ string: _t("Add a line"),
+ });
+ }
+
+ // if addTrashIcon is true, there will be a small trash icon at the end
+ // of each line, so the user can delete a record.
+ this.addTrashIcon = params.addTrashIcon;
+
+ // replace the trash icon by X in case of many2many relations
+ // so that it means 'unlink' instead of 'remove'
+ this.isMany2Many = params.isMany2Many;
+
+ this.currentRow = null;
+ this.currentFieldIndex = null;
+ this.isResizing = false;
+ this.eventListeners = [];
+ },
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ start: function () {
+ core.bus.on('click', this, this._onWindowClicked.bind(this));
+ core.bus.on('resize', this, _.debounce(this._onResize.bind(this), this.RESIZE_DELAY));
+ core.bus.on('DOM_updated', this, () => this._freezeColumnWidths());
+ return this._super();
+ },
+ /**
+ * Overriden to unbind all attached listeners
+ *
+ * @override
+ */
+ destroy: function () {
+ this.eventListeners.forEach(listener => {
+ const { type, el, callback, options } = listener;
+ el.removeEventListener(type, callback, options);
+ });
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * The list renderer needs to know if it is in the DOM, and to be notified
+ * when it is attached to the DOM to properly compute column widths.
+ *
+ * @override
+ */
+ on_attach_callback: function () {
+ this.isInDOM = true;
+ this._super();
+ // _freezeColumnWidths requests style information, which produces a
+ // repaint, so we call it after _super to prevent flickering (in case
+ // other code would also modify the DOM post rendering/before repaint)
+ this._freezeColumnWidths();
+ },
+ /**
+ * The list renderer needs to know if it is in the DOM to properly compute
+ * column widths.
+ *
+ * @override
+ */
+ on_detach_callback: function () {
+ this.isInDOM = false;
+ this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * If the given recordID is the list main one (or that no recordID is
+ * given), then the whole view can be saved if one of the two following
+ * conditions is true:
+ * - There is no line in edition (all lines are saved so they are all valid)
+ * - The line in edition can be saved
+ *
+ * If the given recordID is a record in the list, toggle a className on its
+ * row's cells for invalid fields, so that we can style those cells
+ * differently.
+ *
+ * @override
+ * @param {string} [recordID]
+ * @returns {string[]}
+ */
+ canBeSaved: function (recordID) {
+ if ((recordID || this.state.id) === this.state.id) {
+ recordID = this.getEditableRecordID();
+ if (recordID === null) {
+ return [];
+ }
+ }
+ var fieldNames = this._super(recordID);
+ this.$('.o_selected_row .o_data_cell').removeClass('o_invalid_cell');
+ this.$('.o_selected_row .o_data_cell:has(> .o_field_invalid)').addClass('o_invalid_cell');
+ return fieldNames;
+ },
+ /**
+ * We need to override the confirmChange method from BasicRenderer to
+ * reevaluate the row decorations. Since they depends on the current value
+ * of the row, they might have changed between each edit.
+ *
+ * @override
+ */
+ confirmChange: function (state, recordID) {
+ var self = this;
+ return this._super.apply(this, arguments).then(function (widgets) {
+ if (widgets.length) {
+ var $row = self._getRow(recordID);
+ var record = self._getRecord(recordID);
+ self._setDecorationClasses($row, self.rowDecorations, record);
+ self._updateFooter();
+ }
+ return widgets;
+ });
+ },
+ /**
+ * This is a specialized version of confirmChange, meant to be called when
+ * the change may have affected more than one line (so, for example, an
+ * onchange which add/remove a few lines in a x2many. This does not occur
+ * in a normal list view).
+ *
+ * The update is more difficult when other rows could have been changed. We
+ * need to potentially remove some lines, add some other lines, update some
+ * other lines and maybe reorder a few of them. This problem would neatly
+ * be solved by using a virtual dom, but we do not have this luxury yet.
+ * So, in the meantime, what we do is basically remove every current row
+ * except the 'main' one (the row which caused the update), then rerender
+ * every new row and add them before/after the main one.
+ *
+ * Note that this function assumes that the list isn't grouped, which is
+ * fine as it's never the case for x2many lists.
+ *
+ * @param {Object} state
+ * @param {string} id
+ * @param {string[]} fields
+ * @param {OdooEvent} ev
+ * @returns {Promise<AbstractField[]>} resolved with the list of widgets
+ * that have been reset
+ */
+ confirmUpdate: function (state, id, fields, ev) {
+ var self = this;
+
+ var oldData = this.state.data;
+ this._setState(state);
+ return this.confirmChange(state, id, fields, ev).then(function () {
+ // If no record with 'id' can be found in the state, the
+ // confirmChange method will have rerendered the whole view already,
+ // so no further work is necessary.
+ var record = self._getRecord(id);
+ if (!record) {
+ return;
+ }
+
+ _.each(oldData, function (rec) {
+ if (rec.id !== id) {
+ self._destroyFieldWidgets(rec.id);
+ }
+ });
+
+ // re-render whole body (outside the dom)
+ self.defs = [];
+ var $newBody = self._renderBody();
+ var defs = self.defs;
+ delete self.defs;
+
+ return Promise.all(defs).then(function () {
+ // update registered modifiers to edit 'mode' because the call to
+ // _renderBody set baseModeByRecord as 'readonly'
+ _.each(self.columns, function (node) {
+ self._registerModifiers(node, record, null, {mode: 'edit'});
+ });
+
+ // store the selection range to restore it once the table will
+ // be re-rendered, and the current cell re-selected
+ var currentRowID;
+ var currentWidget;
+ var focusedElement;
+ var selectionRange;
+ if (self.currentRow !== null) {
+ currentRowID = self._getRecordID(self.currentRow);
+ currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex];
+ if (currentWidget) {
+ focusedElement = currentWidget.getFocusableElement().get(0);
+ if (currentWidget.formatType !== 'boolean' && focusedElement) {
+ selectionRange = dom.getSelectionRange(focusedElement);
+ }
+ }
+ }
+
+ // remove all data rows except the one being edited, and insert
+ // data rows of the re-rendered body before and after it
+ var $editedRow = self._getRow(id);
+ $editedRow.nextAll('.o_data_row').remove();
+ $editedRow.prevAll('.o_data_row').remove();
+ var $newRow = $newBody.find('.o_data_row[data-id="' + id + '"]');
+ $newRow.prevAll('.o_data_row').get().reverse().forEach(function (row) {
+ $(row).insertBefore($editedRow);
+ });
+ $newRow.nextAll('.o_data_row').get().reverse().forEach(function (row) {
+ $(row).insertAfter($editedRow);
+ });
+
+ if (self.currentRow !== null) {
+ var newRowIndex = $editedRow.prop('rowIndex') - 1;
+ self.currentRow = newRowIndex;
+ return self._selectCell(newRowIndex, self.currentFieldIndex, {force: true})
+ .then(function () {
+ // restore the selection range
+ currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex];
+ if (currentWidget) {
+ focusedElement = currentWidget.getFocusableElement().get(0);
+ if (selectionRange) {
+ dom.setSelectionRange(focusedElement, selectionRange);
+ }
+ }
+ });
+ }
+ });
+ });
+ },
+ /**
+ * Edit the first record in the list
+ */
+ editFirstRecord: function (ev) {
+ const $borderRow = this._getBorderRow(ev.data.side || 'first');
+ this._selectCell($borderRow.prop('rowIndex') - 1, ev.data.cellIndex || 0);
+ },
+ /**
+ * Edit a given record in the list
+ *
+ * @param {string} recordID
+ */
+ editRecord: function (recordID) {
+ var $row = this._getRow(recordID);
+ var rowIndex = $row.prop('rowIndex') - 1;
+ this._selectCell(rowIndex, 0);
+ },
+ /**
+ * Gives focus to a specific cell, given its row and its related column.
+ *
+ * @param {string} recordId
+ * @param {Object} column
+ */
+ focusCell: function (recordId, column) {
+ var $row = this._getRow(recordId);
+ var cellIndex = this.columns.indexOf(column);
+ $row.find('.o_data_cell')[cellIndex].focus();
+ },
+ /**
+ * Returns the recordID associated to the line which is currently in edition
+ * or null if there is no line in edition.
+ *
+ * @returns {string|null}
+ */
+ getEditableRecordID: function () {
+ if (this.currentRow !== null) {
+ return this._getRecordID(this.currentRow);
+ }
+ return null;
+ },
+ /**
+ * Returns whether the list is in multiple record edition from a given record.
+ *
+ * @private
+ * @param {string} recordId
+ * @returns {boolean}
+ */
+ isInMultipleRecordEdition: function (recordId) {
+ return this.isEditable() && this.isMultiEditable && this.selection.includes(recordId);
+ },
+ /**
+ * Returns whether the list can be edited.
+ * It's true when:
+ * - the list `editable` property is set,
+ * - or at least one record is selected (becomes partially editable)
+ *
+ * @returns {boolean}
+ */
+ isEditable: function () {
+ return this.editable || (this.isMultiEditable && this.selection.length);
+ },
+ /**
+ * Removes the line associated to the given recordID (the index of the row
+ * is found thanks to the old state), then updates the state.
+ *
+ * @param {Object} state
+ * @param {string} recordID
+ */
+ removeLine: function (state, recordID) {
+ this._setState(state);
+ var $row = this._getRow(recordID);
+ if ($row.length === 0) {
+ return;
+ }
+ if ($row.prop('rowIndex') - 1 === this.currentRow) {
+ this.currentRow = null;
+ this._enableRecordSelectors();
+ }
+
+ // destroy widgets first
+ this._destroyFieldWidgets(recordID);
+ // remove the row
+ if (this.state.count >= 4) {
+ $row.remove();
+ } else {
+ // we want to always keep at least 4 (possibly empty) rows
+ var $emptyRow = this._renderEmptyRow();
+ $row.replaceWith($emptyRow);
+ // move the empty row we just inserted after last data row
+ const $lastDataRow = this.$('.o_data_row:last');
+ if ($lastDataRow.length) {
+ $emptyRow.insertAfter($lastDataRow);
+ }
+ }
+ },
+ /**
+ * Updates the already rendered row associated to the given recordID so that
+ * it fits the given mode.
+ *
+ * @param {string} recordID
+ * @param {string} mode
+ * @returns {Promise}
+ */
+ setRowMode: function (recordID, mode) {
+ var self = this;
+ var record = self._getRecord(recordID);
+ if (!record) {
+ return Promise.resolve();
+ }
+
+ var editMode = (mode === 'edit');
+ var $row = this._getRow(recordID);
+ this.currentRow = editMode ? $row.prop('rowIndex') - 1 : null;
+ var $tds = $row.children('.o_data_cell');
+ var oldWidgets = _.clone(this.allFieldWidgets[record.id]);
+
+ // Prepare options for cell rendering (this depends on the mode)
+ var options = {
+ renderInvisible: editMode,
+ renderWidgets: editMode,
+ };
+ options.mode = editMode ? 'edit' : 'readonly';
+
+ // Switch each cell to the new mode; note: the '_renderBodyCell'
+ // function might fill the 'this.defs' variables with multiple promise
+ // so we create the array and delete it after the rendering.
+ var defs = [];
+ this.defs = defs;
+ _.each(this.columns, function (node, colIndex) {
+ var $td = $tds.eq(colIndex);
+ var $newTd = self._renderBodyCell(record, node, colIndex, options);
+
+ // Widgets are unregistered of modifiers data when they are
+ // destroyed. This is not the case for simple buttons so we have to
+ // do it here.
+ if ($td.hasClass('o_list_button')) {
+ self._unregisterModifiersElement(node, recordID, $td.children());
+ }
+
+ // For edit mode we only replace the content of the cell with its
+ // new content (invisible fields, editable fields, ...).
+ // For readonly mode, we replace the whole cell so that the
+ // dimensions of the cell are not forced anymore.
+ if (editMode) {
+ $td.empty().append($newTd.contents());
+ } else {
+ self._unregisterModifiersElement(node, recordID, $td);
+ $td.replaceWith($newTd);
+ }
+ });
+ delete this.defs;
+
+ // Destroy old field widgets
+ _.each(oldWidgets, this._destroyFieldWidget.bind(this, recordID));
+
+ // Toggle selected class here so that style is applied at the end
+ $row.toggleClass('o_selected_row', editMode);
+ if (editMode) {
+ this._disableRecordSelectors();
+ } else {
+ this._enableRecordSelectors();
+ }
+
+ return Promise.all(defs).then(function () {
+ // mark Owl sub components as mounted
+ WidgetAdapterMixin.on_attach_callback.call(self);
+
+ // necessary to trigger resize on fieldtexts
+ core.bus.trigger('DOM_updated');
+ });
+ },
+ /**
+ * This method is called whenever we click/move outside of a row that was
+ * in edit mode. This is the moment we save all accumulated changes on that
+ * row, if needed (@see BasicController.saveRecord).
+ *
+ * Note that we have to disable the focusable elements (inputs, ...) to
+ * prevent subsequent editions. These edits would be lost, because the list
+ * view only saves records when unselecting a row.
+ *
+ * @returns {Promise} The promise resolves if the row was unselected (and
+ * possibly removed). If may be rejected, when the row is dirty and the
+ * user refuses to discard its changes.
+ */
+ unselectRow: function () {
+ // Protect against calling this method when no row is selected
+ if (this.currentRow === null) {
+ return Promise.resolve();
+ }
+ var recordID = this._getRecordID(this.currentRow);
+ var recordWidgets = this.allFieldWidgets[recordID];
+ function toggleWidgets(disabled) {
+ _.each(recordWidgets, function (widget) {
+ var $el = widget.getFocusableElement();
+ $el.prop('disabled', disabled);
+ });
+ }
+
+ toggleWidgets(true);
+ return new Promise((resolve, reject) => {
+ this.trigger_up('save_line', {
+ recordID: recordID,
+ onSuccess: resolve,
+ onFailure: reject,
+ });
+ }).then(selectNextRow => {
+ this._enableRecordSelectors();
+ // If any field has changed and if the list is in multiple edition,
+ // we send a truthy boolean to _selectRow to tell it not to select
+ // the following record.
+ return selectNextRow;
+ }).guardedCatch(() => {
+ toggleWidgets(false);
+ });
+ },
+ /**
+ * @override
+ */
+ updateState: function (state, params) {
+ // There are some cases where a record is added to an invisible list
+ // e.g. set a quotation template with optionnal products
+ if (params.keepWidths && this.$el.is(':visible')) {
+ this._storeColumnWidths();
+ }
+ if (params.noRender) {
+ // the state changed, but we won't do a re-rendering right now, so
+ // remove computed modifiers data (as they are obsolete) to force
+ // them to be recomputed at next (sub-)rendering
+ this.allModifiersData = [];
+ }
+ if ('addTrashIcon' in params) {
+ if (this.addTrashIcon !== params.addTrashIcon) {
+ this.columnWidths = false; // columns changed, so forget stored widths
+ }
+ this.addTrashIcon = params.addTrashIcon;
+ }
+ if ('addCreateLine' in params) {
+ this.addCreateLine = params.addCreateLine;
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Used to bind event listeners so that they can be unbound when the list
+ * is destroyed.
+ * There is no reverse method (list._removeEventListener) because there is
+ * no issue with removing an non-existing listener.
+ *
+ * @private
+ * @param {string} type event name
+ * @param {EventTarget} el event target
+ * @param {Function} callback callback function to attach
+ * @param {Object} options event listener options
+ */
+ _addEventListener: function (type, el, callback, options) {
+ el.addEventListener(type, callback, options);
+ this.eventListeners.push({ type, el, callback, options });
+ },
+ /**
+ * Handles the assignation of default widths for each column header.
+ * If the list is empty, an arbitrary absolute or relative width will be
+ * given to the header
+ *
+ * @see _getColumnWidth for detailed information about which width is
+ * given to a certain field type.
+ *
+ * @private
+ */
+ _computeDefaultWidths: function () {
+ const isListEmpty = !this._hasVisibleRecords(this.state);
+ const relativeWidths = [];
+ this.columns.forEach(column => {
+ const th = this._getColumnHeader(column);
+ if (th.offsetParent === null) {
+ relativeWidths.push(false);
+ } else {
+ const width = this._getColumnWidth(column);
+ if (width.match(/[a-zA-Z]/)) { // absolute width with measure unit (e.g. 100px)
+ if (isListEmpty) {
+ th.style.width = width;
+ } else {
+ // If there are records, we force a min-width for fields with an absolute
+ // width to ensure a correct rendering in edition
+ th.style.minWidth = width;
+ }
+ relativeWidths.push(false);
+ } else { // relative width expressed as a weight (e.g. 1.5)
+ relativeWidths.push(parseFloat(width, 10));
+ }
+ }
+ });
+
+ // Assignation of relative widths
+ if (isListEmpty) {
+ const totalWidth = this._getColumnsTotalWidth(relativeWidths);
+ for (let i in this.columns) {
+ if (relativeWidths[i]) {
+ const th = this._getColumnHeader(this.columns[i]);
+ th.style.width = (relativeWidths[i] / totalWidth * 100) + '%';
+ }
+ }
+ // Manualy assigns trash icon header width since it's not in the columns
+ const trashHeader = this.el.getElementsByClassName('o_list_record_remove_header')[0];
+ if (trashHeader) {
+ trashHeader.style.width = '32px';
+ }
+ }
+ },
+ /**
+ * Destroy all field widgets corresponding to a record. Useful when we are
+ * removing a useless row.
+ *
+ * @param {string} recordID
+ */
+ _destroyFieldWidgets: function (recordID) {
+ if (recordID in this.allFieldWidgets) {
+ var widgetsToDestroy = this.allFieldWidgets[recordID].slice();
+ _.each(widgetsToDestroy, this._destroyFieldWidget.bind(this, recordID));
+ delete this.allFieldWidgets[recordID];
+ }
+ },
+ /**
+ * When editing a row, we want to disable all record selectors.
+ *
+ * @private
+ */
+ _disableRecordSelectors: function () {
+ this.$('.o_list_record_selector input').attr('disabled', 'disabled');
+ },
+ /**
+ * @private
+ */
+ _enableRecordSelectors: function () {
+ this.$('.o_list_record_selector input').attr('disabled', false);
+ },
+ /**
+ * This function freezes the column widths and forces a fixed table-layout,
+ * once the browser has computed the optimal width of each column according
+ * to the displayed records. We want to freeze widths s.t. it doesn't
+ * flicker when we switch a row in edition.
+ *
+ * We skip this when there is no record as we don't want to fix widths
+ * according to column's labels. In this case, we fallback on the 'weight'
+ * heuristic, which assigns to each column a fixed or relative width
+ * depending on the widget or field type.
+ *
+ * Note that the list must be in the DOM when this function is called.
+ *
+ * @private
+ */
+ _freezeColumnWidths: function () {
+ if (!this.columnWidths && this.el.offsetParent === null) {
+ // there is no record nor widths to restore or the list is not visible
+ // -> don't force column's widths w.r.t. their label
+ return;
+ }
+ const thElements = [...this.el.querySelectorAll('table thead th')];
+ if (!thElements.length) {
+ return;
+ }
+ const table = this.el.getElementsByClassName('o_list_table')[0];
+ let columnWidths = this.columnWidths;
+
+ if (!columnWidths || !columnWidths.length) { // no column widths to restore
+ // Set table layout auto and remove inline style to make sure that css
+ // rules apply (e.g. fixed width of record selector)
+ table.style.tableLayout = 'auto';
+ thElements.forEach(th => {
+ th.style.width = null;
+ th.style.maxWidth = null;
+ });
+
+ // Resets the default widths computation now that the table is visible.
+ this._computeDefaultWidths();
+
+ // Squeeze the table by applying a max-width on largest columns to
+ // ensure that it doesn't overflow
+ columnWidths = this._squeezeTable();
+ }
+
+ thElements.forEach((th, index) => {
+ // Width already set by default relative width computation
+ if (!th.style.width) {
+ th.style.width = `${columnWidths[index]}px`;
+ }
+ });
+
+ // Set the table layout to fixed
+ table.style.tableLayout = 'fixed';
+ },
+ /**
+ * Returns the first or last editable row of the list
+ *
+ * @private
+ * @returns {integer}
+ */
+ _getBorderRow: function (side) {
+ let $borderDataRow = this.$(`.o_data_row:${side}`);
+ if (!this._isRecordEditable($borderDataRow.data('id'))) {
+ $borderDataRow = this._getNearestEditableRow($borderDataRow, side === 'first');
+ }
+ return $borderDataRow;
+ },
+ /**
+ * Compute the sum of the weights for each column, given an array containing
+ * all relative widths. param `$thead` is useful for studio, in order to
+ * show column hooks.
+ *
+ * @private
+ * @param {jQuery} $thead
+ * @param {number[]} relativeWidths
+ * @return {integer}
+ */
+ _getColumnsTotalWidth(relativeWidths) {
+ return relativeWidths.reduce((acc, width) => acc + width, 0);
+ },
+ /**
+ * Returns the width of a column according the 'width' attribute set in the
+ * arch, the widget or the field type. A fixed width is harcoded for some
+ * field types (e.g. date and numeric fields). By default, the remaining
+ * space is evenly distributed between the other fields (with a factor '1').
+ *
+ * This is only used when there is no record in the list (i.e. when we can't
+ * let the browser compute the optimal width of each column).
+ *
+ * @see _renderHeader
+ * @private
+ * @param {Object} column an arch node
+ * @returns {string} either a weight factor (e.g. '1.5') or a css width
+ * description (e.g. '120px')
+ */
+ _getColumnWidth: function (column) {
+ if (column.attrs.width) {
+ return column.attrs.width;
+ }
+ const fieldsInfo = this.state.fieldsInfo.list;
+ const name = column.attrs.name;
+ if (!fieldsInfo[name]) {
+ // Unnamed columns get default value
+ return '1';
+ }
+ const widget = fieldsInfo[name].Widget.prototype;
+ if ('widthInList' in widget) {
+ return widget.widthInList;
+ }
+ const field = this.state.fields[name];
+ if (!field) {
+ // this is not a field. Probably a button or something of unknown
+ // width.
+ return '1';
+ }
+ const fixedWidths = {
+ boolean: '70px',
+ date: '92px',
+ datetime: '146px',
+ float: '92px',
+ integer: '74px',
+ monetary: '104px',
+ };
+ let type = field.type;
+ if (fieldsInfo[name].widget in fixedWidths) {
+ type = fieldsInfo[name].widget;
+ }
+ return fixedWidths[type] || '1';
+ },
+ /**
+ * Gets the th element corresponding to a given column.
+ *
+ * @private
+ * @param {Object} column
+ * @returns {HTMLElement}
+ */
+ _getColumnHeader: function (column) {
+ const { icon, name, string } = column.attrs;
+ if (name) {
+ return this.el.querySelector(`thead th[data-name="${name}"]`);
+ } else if (string) {
+ return this.el.querySelector(`thead th[data-string="${string}"]`);
+ } else if (icon) {
+ return this.el.querySelector(`thead th[data-icon="${icon}"]`);
+ }
+ },
+ /**
+ * Returns the nearest editable row starting from a given table row.
+ * If the list is grouped, jumps to the next unfolded group
+ *
+ * @private
+ * @param {jQuery} $row starting point
+ * @param {boolean} next whether the requested row should be the next or the previous one
+ * @return {jQuery|null}
+ */
+ _getNearestEditableRow: function ($row, next) {
+ const direction = next ? 'next' : 'prev';
+ let $nearestRow;
+ if (this.editable) {
+ $nearestRow = $row[direction]();
+ if (!$nearestRow.hasClass('o_data_row')) {
+ var $nextBody = $row.closest('tbody')[direction]();
+ while ($nextBody.length && !$nextBody.find('.o_data_row').length) {
+ $nextBody = $nextBody[direction]();
+ }
+ $nearestRow = $nextBody.find(`.o_data_row:${next ? 'first' : 'last'}`);
+ }
+ } else {
+ // In readonly lists, look directly into selected records
+ const recordId = $row.data('id');
+ const rowSelectionIndex = this.selection.indexOf(recordId);
+ let nextRowIndex;
+ if (rowSelectionIndex < 0) {
+ nextRowIndex = next ? 0 : this.selection.length - 1;
+ } else {
+ nextRowIndex = rowSelectionIndex + (next ? 1 : -1);
+ }
+ // Index might be out of range, will then return an empty jQuery object
+ $nearestRow = this._getRow(this.selection[nextRowIndex]);
+ }
+ return $nearestRow;
+ },
+ /**
+ * Returns the current number of columns. The editable renderer may add a
+ * trash icon on the right of a record, so we need to take this into account
+ *
+ * @override
+ * @returns {number}
+ */
+ _getNumberOfCols: function () {
+ var n = this._super();
+ if (this.addTrashIcon) {
+ n++;
+ }
+ return n;
+ },
+ /**
+ * Traverse this.state to find and return the record with given dataPoint id
+ * (for grouped list views, the record could be deep down in state tree).
+ *
+ * @override
+ * @private
+ */
+ _getRecord: function (recordId) {
+ var record;
+ utils.traverse_records(this.state, function (r) {
+ if (r.id === recordId) {
+ record = r;
+ }
+ });
+ return record;
+ },
+ /**
+ * Retrieve the record dataPoint id from a rowIndex as the row DOM element
+ * stores the record id in data.
+ *
+ * @private
+ * @param {integer} rowIndex
+ * @returns {string} record dataPoint id
+ */
+ _getRecordID: function (rowIndex) {
+ var $tr = this.$('table.o_list_table > tbody tr').eq(rowIndex);
+ return $tr.data('id');
+ },
+ /**
+ * Return the jQuery tr element corresponding to the given record dataPoint
+ * id.
+ *
+ * @private
+ * @param {string} [recordId]
+ * @returns {jQueryElement}
+ */
+ _getRow: function (recordId) {
+ return this.$('.o_data_row[data-id="' + recordId + '"]');
+ },
+ /**
+ * This function returns true iff records are visible in the list, i.e.
+ * if the list is ungrouped: true iff the list isn't empty;
+ * if the list is grouped: true iff there is at least one unfolded group
+ * containing records.
+ *
+ * @param {Object} list a datapoint
+ * @returns {boolean}
+ */
+ _hasVisibleRecords: function (list) {
+ if (!list.groupedBy.length) {
+ return !!list.data.length;
+ } else {
+ var hasVisibleRecords = false;
+ for (var i = 0; i < list.data.length; i++) {
+ hasVisibleRecords = hasVisibleRecords || this._hasVisibleRecords(list.data[i]);
+ }
+ return hasVisibleRecords;
+ }
+ },
+ /**
+ * Returns whether a recordID is currently editable.
+ *
+ * @param {string} recordID
+ * @returns {boolean}
+ */
+ _isRecordEditable: function (recordID) {
+ return this.editable || (this.isMultiEditable && this.selection.includes(recordID));
+ },
+ /**
+ * Moves to the next row in the list
+ *
+ * @private
+ * @params {Object} [options] see @_moveToSideLine
+ */
+ _moveToNextLine: function (options) {
+ this._moveToSideLine(true, options);
+ },
+ /**
+ * Moves to the previous row in the list
+ *
+ * @private
+ * @params {Object} [options] see @_moveToSideLine
+ */
+ _moveToPreviousLine: function (options) {
+ this._moveToSideLine(false, options);
+ },
+ /**
+ * Moves the focus to the nearest editable row before or after the current one.
+ * If we arrive at the end of the list (or of a group in the grouped case) and the list
+ * is editable="bottom", we create a new record, otherwise, we move the
+ * cursor to the first row (of the next group in the grouped case).
+ *
+ * @private
+ * @param {number} next whether to move to the next or previous row
+ * @param {Object} [options]
+ * @param {boolean} [options.forceCreate=false] typically set to true when
+ * navigating with ENTER ; in this case, if the next row is the 'Add a
+ * row' one, always create a new record (never skip it, like TAB does
+ * under some conditions)
+ */
+ _moveToSideLine: function (next, options) {
+ options = options || {};
+ const recordID = this._getRecordID(this.currentRow);
+ this.commitChanges(recordID).then(() => {
+ const record = this._getRecord(recordID);
+ const multiEdit = this.isInMultipleRecordEdition(recordID);
+ if (!multiEdit) {
+ const fieldNames = this.canBeSaved(recordID);
+ if (fieldNames.length && (record.isDirty() || options.forceCreate)) {
+ // the current row is invalid, we only leave it if it is not dirty
+ // (we didn't make any change on this row, which is a new one) and
+ // we are navigating with TAB (forceCreate=false)
+ return;
+ }
+ }
+ // compute the index of the next (record) row to select, if any
+ const side = next ? 'first' : 'last';
+ const borderRowIndex = this._getBorderRow(side).prop('rowIndex') - 1;
+ const cellIndex = next ? 0 : this.allFieldWidgets[recordID].length - 1;
+ const cellOptions = { inc: next ? 1 : -1, force: true };
+ const $currentRow = this._getRow(recordID);
+ const $nextRow = this._getNearestEditableRow($currentRow, next);
+ let nextRowIndex = null;
+ let groupId;
+
+ if (!this.isGrouped) {
+ // ungrouped case
+ if ($nextRow.length) {
+ nextRowIndex = $nextRow.prop('rowIndex') - 1;
+ } else if (!this.editable) {
+ nextRowIndex = borderRowIndex;
+ } else if (!options.forceCreate && !record.isDirty()) {
+ this.trigger_up('discard_changes', {
+ recordID: recordID,
+ onSuccess: this.trigger_up.bind(this, 'activate_next_widget', { side: side }),
+ });
+ return;
+ }
+ } else {
+ // grouped case
+ var $directNextRow = $currentRow.next();
+ if (next && this.editable === "bottom" && $directNextRow.hasClass('o_add_record_row')) {
+ // the next row is the 'Add a line' row (i.e. the current one is the last record
+ // row of the group)
+ if (options.forceCreate || record.isDirty()) {
+ // if we modified the current record, add a row to create a new record
+ groupId = $directNextRow.data('group-id');
+ } else {
+ // if we didn't change anything to the current line (e.g. we pressed TAB on
+ // each cell without modifying/entering any data), we discard that line (if
+ // it was a new one) and move to the first record of the next group
+ nextRowIndex = ($nextRow.prop('rowIndex') - 1) || null;
+ this.trigger_up('discard_changes', {
+ recordID: recordID,
+ onSuccess: () => {
+ if (nextRowIndex !== null) {
+ if (!record.res_id) {
+ // the current record was a new one, so we decrement
+ // nextRowIndex as that row has been removed meanwhile
+ nextRowIndex--;
+ }
+ this._selectCell(nextRowIndex, cellIndex, cellOptions);
+ } else {
+ // we were in the last group, so go back to the top
+ this._selectCell(borderRowIndex, cellIndex, cellOptions);
+ }
+ },
+ });
+ return;
+ }
+ } else {
+ // there is no 'Add a line' row (i.e. the create feature is disabled), or the
+ // list is editable="top", we focus the first record of the next group if any,
+ // or we go back to the top of the list
+ nextRowIndex = $nextRow.length ?
+ ($nextRow.prop('rowIndex') - 1) :
+ borderRowIndex;
+ }
+ }
+
+ // if there is a (record) row to select, select it, otherwise, add a new record (in the
+ // correct group, if the view is grouped)
+ if (nextRowIndex !== null) {
+ // cellOptions.force = true;
+ this._selectCell(nextRowIndex, cellIndex, cellOptions);
+ } else if (this.editable) {
+ // if for some reason (e.g. create feature is disabled) we can't add a new
+ // record, select the first record row
+ this.unselectRow().then(this.trigger_up.bind(this, 'add_record', {
+ groupId: groupId,
+ onFail: this._selectCell.bind(this, borderRowIndex, cellIndex, cellOptions),
+ }));
+ }
+ });
+ },
+ /**
+ * Override to compute the (relative or absolute) width of each column.
+ *
+ * @override
+ * @private
+ */
+ _processColumns: function () {
+ const oldColumns = this.columns;
+ this._super.apply(this, arguments);
+ // check if stored widths still apply
+ if (this.columnWidths && oldColumns && oldColumns.length === this.columns.length) {
+ for (let i = 0; i < oldColumns.length; i++) {
+ if (oldColumns[i] !== this.columns[i]) {
+ this.columnWidths = false; // columns changed, so forget stored widths
+ break;
+ }
+ }
+ } else {
+ this.columnWidths = false; // columns changed, so forget stored widths
+ }
+ },
+ /**
+ * @override
+ * @returns {Promise}
+ */
+ _render: function () {
+ this.currentRow = null;
+ this.currentFieldIndex = null;
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Override to add the 'Add an item' link to the end of last-level opened
+ * groups.
+ *
+ * @override
+ * @private
+ */
+ _renderGroup: function (group) {
+ var result = this._super.apply(this, arguments);
+ if (!group.groupedBy.length && this.addCreateLineInGroups) {
+ var $groupBody = result[0];
+ var $a = $('<a href="#" role="button">')
+ .text(_t("Add a line"))
+ .attr('data-group-id', group.id);
+ var $td = $('<td>')
+ .attr('colspan', this._getNumberOfCols())
+ .addClass('o_group_field_row_add')
+ .attr('tabindex', -1)
+ .append($a);
+ var $tr = $('<tr>', {class: 'o_add_record_row'})
+ .attr('data-group-id', group.id)
+ .append($td);
+ $groupBody.append($tr.prepend($('<td>').html('&nbsp;')));
+ }
+ return result;
+ },
+ /**
+ * The renderer needs to support reordering lines. This is only active in
+ * edit mode. The handleField attribute is set when there is a sequence
+ * widget.
+ *
+ * @override
+ */
+ _renderBody: function () {
+ var self = this;
+ var $body = this._super.apply(this, arguments);
+ if (this.hasHandle) {
+ $body.sortable({
+ axis: 'y',
+ items: '> tr.o_data_row',
+ helper: 'clone',
+ handle: '.o_row_handle',
+ stop: function (event, ui) {
+ // update currentID taking moved line into account
+ if (self.currentRow !== null) {
+ var currentID = self.state.data[self.currentRow].id;
+ self.currentRow = self._getRow(currentID).index();
+ }
+ self.unselectRow().then(function () {
+ self._moveRecord(ui.item.data('id'), ui.item.index());
+ });
+ },
+ });
+ }
+ return $body;
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderFooter: function () {
+ const $footer = this._super.apply(this, arguments);
+ if (this.addTrashIcon) {
+ $footer.find('tr').append($('<td>'));
+ }
+ return $footer;
+ },
+ /**
+ * Override to optionally add a th in the header for the remove icon column.
+ *
+ * @override
+ * @private
+ */
+ _renderHeader: function () {
+ var $thead = this._super.apply(this, arguments);
+ if (this.addTrashIcon) {
+ $thead.find('tr').append($('<th>', {class: 'o_list_record_remove_header'}));
+ }
+ return $thead;
+ },
+ /**
+ * Overriden to add a resize handle in editable list column headers.
+ * Only applies to headers containing text.
+ *
+ * @override
+ * @private
+ */
+ _renderHeaderCell: function () {
+ const $th = this._super.apply(this, arguments);
+ if ($th[0].innerHTML.length && this._hasVisibleRecords(this.state)) {
+ const resizeHandle = document.createElement('span');
+ resizeHandle.classList = 'o_resize';
+ resizeHandle.onclick = this._onClickResize.bind(this);
+ resizeHandle.onmousedown = this._onStartResize.bind(this);
+ $th.append(resizeHandle);
+ }
+ return $th;
+ },
+ /**
+ * Editable rows are possibly extended with a trash icon on their right, to
+ * allow deleting the corresponding record.
+ * For many2many editable lists, the trash bin is replaced by X.
+ *
+ * @override
+ * @param {any} record
+ * @param {any} index
+ * @returns {jQueryElement}
+ */
+ _renderRow: function (record, index) {
+ var $row = this._super.apply(this, arguments);
+ if (this.addTrashIcon) {
+ var $icon = this.isMany2Many ?
+ $('<button>', {'class': 'fa fa-times', 'name': 'unlink', 'aria-label': _t('Unlink row ') + (index + 1)}) :
+ $('<button>', {'class': 'fa fa-trash-o', 'name': 'delete', 'aria-label': _t('Delete row ') + (index + 1)});
+ var $td = $('<td>', {class: 'o_list_record_remove'}).append($icon);
+ $row.append($td);
+ }
+ return $row;
+ },
+ /**
+ * If the editable list view has the parameter addCreateLine, we need to
+ * add a last row with the necessary control.
+ *
+ * If the list has a handleField, we want to left-align the first button
+ * on the first real column.
+ *
+ * @override
+ * @returns {jQueryElement[]}
+ */
+ _renderRows: function () {
+ var $rows = this._super();
+ if (this.addCreateLine) {
+ var $tr = $('<tr>');
+ var colspan = this._getNumberOfCols();
+
+ if (this.handleField) {
+ colspan = colspan - 1;
+ $tr.append('<td>');
+ }
+
+ var $td = $('<td>')
+ .attr('colspan', colspan)
+ .addClass('o_field_x2many_list_row_add');
+ $tr.append($td);
+ $rows.push($tr);
+
+ if (this.addCreateLine) {
+ _.each(this.creates, function (create, index) {
+ var $a = $('<a href="#" role="button">')
+ .attr('data-context', create.context)
+ .text(create.string);
+ if (index > 0) {
+ $a.addClass('ml16');
+ }
+ $td.append($a);
+ });
+ }
+ }
+ return $rows;
+ },
+ /**
+ * @override
+ * @private
+ * @returns {Promise} this promise is resolved immediately
+ */
+ _renderView: function () {
+ this.currentRow = null;
+ return this._super.apply(this, arguments).then(() => {
+ const table = this.el.getElementsByClassName('o_list_table')[0];
+ if (table) {
+ table.classList.toggle('o_empty_list', !this._hasVisibleRecords(this.state));
+ this._freezeColumnWidths();
+ }
+ });
+ },
+ /**
+ * This is one of the trickiest method in the editable renderer. It has to
+ * do a lot of stuff: it has to determine which cell should be selected (if
+ * the target cell is readonly, we need to find another suitable cell), then
+ * unselect the current row, and activate the line where the selected cell
+ * is, if necessary.
+ *
+ * @param {integer} rowIndex
+ * @param {integer} fieldIndex
+ * @param {Object} [options]
+ * @param {Event} [options.event] original target of the event which
+ * @param {boolean} [options.wrap=true] if true and no widget could be
+ * triggered the cell selection
+ * selected from the fieldIndex to the last column, then we wrap around and
+ * try to select a widget starting from the beginning
+ * @param {boolean} [options.force=false] if true, force selecting the cell
+ * even if seems to be already the selected one (useful after a re-
+ * rendering, to reset the focus on the correct field)
+ * @param {integer} [options.inc=1] the increment to use when searching for
+ * the "next" possible cell (if the cell to select can't be selected)
+ * @return {Promise} fails if no cell could be selected
+ */
+ _selectCell: function (rowIndex, fieldIndex, options) {
+ options = options || {};
+ // Do nothing if the user tries to select current cell
+ if (!options.force && rowIndex === this.currentRow && fieldIndex === this.currentFieldIndex) {
+ return Promise.resolve();
+ }
+ var wrap = options.wrap === undefined ? true : options.wrap;
+ var recordID = this._getRecordID(rowIndex);
+
+ // Select the row then activate the widget in the correct cell
+ var self = this;
+ return this._selectRow(rowIndex).then(function () {
+ var record = self._getRecord(recordID);
+ if (fieldIndex >= (self.allFieldWidgets[record.id] || []).length) {
+ return Promise.reject();
+ }
+ // _activateFieldWidget might trigger an onchange,
+ // which requires currentFieldIndex to be set
+ // so that the cursor can be restored
+ var oldFieldIndex = self.currentFieldIndex;
+ self.currentFieldIndex = fieldIndex;
+ fieldIndex = self._activateFieldWidget(record, fieldIndex, {
+ inc: options.inc || 1,
+ wrap: wrap,
+ event: options && options.event,
+ });
+ if (fieldIndex < 0) {
+ self.currentFieldIndex = oldFieldIndex;
+ return Promise.reject();
+ }
+ self.currentFieldIndex = fieldIndex;
+ });
+ },
+ /**
+ * Activates the row at the given row index.
+ *
+ * @param {integer} rowIndex
+ * @returns {Promise}
+ */
+ _selectRow: function (rowIndex) {
+ // Do nothing if already selected
+ if (rowIndex === this.currentRow) {
+ return Promise.resolve();
+ }
+ if (!this.columnWidths) {
+ // we don't want the column widths to change when selecting rows
+ this._storeColumnWidths();
+ }
+ var recordId = this._getRecordID(rowIndex);
+ // To select a row, the currently selected one must be unselected first
+ var self = this;
+ return this.unselectRow().then((selectNextRow = true) => {
+ if (!selectNextRow) {
+ return Promise.resolve();
+ }
+ if (!recordId) {
+ // The row to selected doesn't exist anymore (probably because
+ // an onchange triggered when unselecting the previous one
+ // removes rows)
+ return Promise.reject();
+ }
+ // Notify the controller we want to make a record editable
+ return new Promise(function (resolve) {
+ self.trigger_up('edit_line', {
+ recordId: recordId,
+ onSuccess: function () {
+ self._disableRecordSelectors();
+ resolve();
+ },
+ });
+ });
+ });
+ },
+ /**
+ * Set a maximum width on the largest columns in the list in case the table
+ * is overflowing. The idea is to shrink largest columns first, but to
+ * ensure that they are still the largest at the end (maybe in equal measure
+ * with other columns). Button columns aren't impacted by this function, as
+ * we assume that they can't be squeezed (we want all buttons to always be
+ * available, not being replaced by ellipsis).
+ *
+ * @private
+ * @returns {integer[]} width (in px) of each column s.t. the table doesn't
+ * overflow
+ */
+ _squeezeTable: function () {
+ const table = this.el.getElementsByClassName('o_list_table')[0];
+
+ // Toggle a className used to remove style that could interfer with the ideal width
+ // computation algorithm (e.g. prevent text fields from being wrapped during the
+ // computation, to prevent them from being completely crushed)
+ table.classList.add('o_list_computing_widths');
+
+ const thead = table.getElementsByTagName('thead')[0];
+ const thElements = [...thead.getElementsByTagName('th')];
+ const columnWidths = thElements.map(th => th.offsetWidth);
+ const getWidth = th => columnWidths[thElements.indexOf(th)] || 0;
+ const getTotalWidth = () => thElements.reduce((tot, th, i) => tot + columnWidths[i], 0);
+ const shrinkColumns = (columns, width) => {
+ let thresholdReached = false;
+ columns.forEach(th => {
+ const index = thElements.indexOf(th);
+ let maxWidth = columnWidths[index] - Math.ceil(width / columns.length);
+ if (maxWidth < 92) { // prevent the columns from shrinking under 92px (~ date field)
+ maxWidth = 92;
+ thresholdReached = true;
+ }
+ th.style.maxWidth = `${maxWidth}px`;
+ columnWidths[index] = maxWidth;
+ });
+ return thresholdReached;
+ };
+ // Sort columns, largest first
+ const sortedThs = [...thead.querySelectorAll('th:not(.o_list_button)')]
+ .sort((a, b) => getWidth(b) - getWidth(a));
+ const allowedWidth = table.parentNode.offsetWidth;
+
+ let totalWidth = getTotalWidth();
+ let stop = false;
+ let index = 0;
+ while (totalWidth > allowedWidth && !stop) {
+ // Find the largest columns
+ index++;
+ const largests = sortedThs.slice(0, index);
+ while (getWidth(largests[0]) === getWidth(sortedThs[index])) {
+ largests.push(sortedThs[index]);
+ index++;
+ }
+
+ // Compute the number of px to remove from the largest columns
+ const nextLargest = sortedThs[index]; // largest column when omitting those in largests
+ const totalToRemove = totalWidth - allowedWidth;
+ const canRemove = (getWidth(largests[0]) - getWidth(nextLargest)) * largests.length;
+
+ // Shrink the largests columns
+ stop = shrinkColumns(largests, Math.min(totalToRemove, canRemove));
+
+ totalWidth = getTotalWidth();
+ }
+
+ // We are no longer computing widths, so restore the normal style
+ table.classList.remove('o_list_computing_widths');
+
+ return columnWidths;
+ },
+ /**
+ * @private
+ */
+ _storeColumnWidths: function () {
+ this.columnWidths = this.$('thead th').toArray().map(function (th) {
+ return $(th).outerWidth();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method is called when we click on the 'Add a line' button in a groupby
+ * list view.
+ *
+ * @param {MouseEvent} ev
+ */
+ _onAddRecordToGroup: function (ev) {
+ ev.preventDefault();
+ // we don't want the click to cause other effects, such as unselecting
+ // the row that we are creating, because it counts as a click on a tr
+ ev.stopPropagation();
+
+ var self = this;
+ // This method can be called when selecting the parent of the link.
+ // We need to ensure that the link is the actual target
+ const target = ev.target.tagName !== 'A' ? ev.target.getElementsByTagName('A')[0] : ev.target;
+ const groupId = target.dataset.groupId;
+ this.currentGroupId = groupId;
+ this.unselectRow().then(function () {
+ self.trigger_up('add_record', {
+ groupId: groupId,
+ });
+ });
+ },
+ /**
+ * This method is called when we click on the 'Add a line' button in a sub
+ * list such as a one2many in a form view.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddRecord: function (ev) {
+ // we don't want the browser to navigate to a the # url
+ ev.preventDefault();
+
+ // we don't want the click to cause other effects, such as unselecting
+ // the row that we are creating, because it counts as a click on a tr
+ ev.stopPropagation();
+
+ // but we do want to unselect current row
+ var self = this;
+ this.unselectRow().then(function () {
+ self.trigger_up('add_record', {context: ev.currentTarget.dataset.context && [ev.currentTarget.dataset.context]}); // TODO write a test, the promise was not considered
+ });
+ },
+ /**
+ * When the user clicks on a cell, we simply select it.
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onCellClick: function (event) {
+ // The special_click property explicitely allow events to bubble all
+ // the way up to bootstrap's level rather than being stopped earlier.
+ var $td = $(event.currentTarget);
+ var $tr = $td.parent();
+ var rowIndex = $tr.prop('rowIndex') - 1;
+ if (!this._isRecordEditable($tr.data('id')) || $(event.target).prop('special_click')) {
+ return;
+ }
+ var fieldIndex = Math.max($tr.find('.o_field_cell').index($td), 0);
+ this._selectCell(rowIndex, fieldIndex, {event: event});
+ },
+ /**
+ * We want to override any default mouse behaviour when clicking on the resize handles
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickResize: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ },
+ /**
+ * We need to manually unselect row, because no one else would do it
+ */
+ _onEmptyRowClick: function () {
+ this.unselectRow();
+ },
+ /**
+ * Clicking on a footer should unselect (and save) the currently selected
+ * row. It has to be done this way, because this is a click inside this.el,
+ * and _onWindowClicked ignore those clicks.
+ */
+ _onFooterClick: function () {
+ this.unselectRow();
+ },
+ /**
+ * Manages the keyboard events on the list. If the list is not editable, when the user navigates to
+ * a cell using the keyboard, if he presses enter, enter the model represented by the line
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ * @override
+ */
+ _onKeyDown: function (ev) {
+ const $target = $(ev.currentTarget);
+ const $tr = $target.closest('tr');
+ const recordEditable = this._isRecordEditable($tr.data('id'));
+
+ if (recordEditable && ev.keyCode === $.ui.keyCode.ENTER && $tr.hasClass('o_selected_row')) {
+ // enter on a textarea for example, let it bubble
+ return;
+ }
+
+ if (recordEditable && ev.keyCode === $.ui.keyCode.ENTER &&
+ !$tr.hasClass('o_selected_row') && !$tr.hasClass('o_group_header')) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ if ($target.closest('td').hasClass('o_group_field_row_add')) {
+ this._onAddRecordToGroup(ev);
+ } else {
+ this._onCellClick(ev);
+ }
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+ /**
+ * @private
+ * @param {KeyDownEvent} e
+ */
+ _onKeyDownAddRecord: function (e) {
+ switch (e.keyCode) {
+ case $.ui.keyCode.ENTER:
+ e.stopPropagation();
+ e.preventDefault();
+ this._onAddRecord(e);
+ break;
+ }
+ },
+ /**
+ * Handles the keyboard navigation according to events triggered by field
+ * widgets.
+ * - previous: move to the first activable cell on the left if any, if not
+ * move to the rightmost activable cell on the row above.
+ * - next: move to the first activable cell on the right if any, if not move
+ * to the leftmost activable cell on the row below.
+ * - next_line: move to leftmost activable cell on the row below.
+ *
+ * Note: moving to a line below if on the last line or moving to a line
+ * above if on the first line automatically creates a new line.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNavigationMove: function (ev) {
+ var self = this;
+ // Don't stop the propagation when navigating up while not editing any row
+ if (this.currentRow === null && ev.data.direction === 'up') {
+ return;
+ }
+ ev.stopPropagation(); // stop the event, the action is done by this renderer
+ if (ev.data.originalEvent && ['next', 'previous'].includes(ev.data.direction)) {
+ ev.data.originalEvent.preventDefault();
+ ev.data.originalEvent.stopPropagation();
+ }
+ switch (ev.data.direction) {
+ case 'previous':
+ if (this.currentFieldIndex > 0) {
+ this._selectCell(this.currentRow, this.currentFieldIndex - 1, {inc: -1, wrap: false})
+ .guardedCatch(this._moveToPreviousLine.bind(this));
+ } else {
+ this._moveToPreviousLine();
+ }
+ break;
+ case 'next':
+ if (this.currentFieldIndex + 1 < this.columns.length) {
+ this._selectCell(this.currentRow, this.currentFieldIndex + 1, {wrap: false})
+ .guardedCatch(this._moveToNextLine.bind(this));
+ } else {
+ this._moveToNextLine();
+ }
+ break;
+ case 'next_line':
+ // If the list is readonly and the current is the only record editable, we unselect the line
+ if (!this.editable && this.selection.length === 1 &&
+ this._getRecordID(this.currentRow) === ev.target.dataPointID) {
+ this.unselectRow();
+ } else {
+ this._moveToNextLine({ forceCreate: true });
+ }
+ break;
+ case 'cancel':
+ // stop the original event (typically an ESCAPE keydown), to
+ // prevent from closing the potential dialog containing this list
+ // also auto-focus the 1st control, if any.
+ ev.data.originalEvent.stopPropagation();
+ var rowIndex = this.currentRow;
+ var cellIndex = this.currentFieldIndex + 1;
+ this.trigger_up('discard_changes', {
+ recordID: ev.target.dataPointID,
+ onSuccess: function () {
+ self._enableRecordSelectors();
+ var recordId = self._getRecordID(rowIndex);
+ if (recordId) {
+ var correspondingRow = self._getRow(recordId);
+ correspondingRow.children().eq(cellIndex).focus();
+ } else if (self.currentGroupId) {
+ self.$('a[data-group-id="' + self.currentGroupId + '"]').focus();
+ } else {
+ self.$('.o_field_x2many_list_row_add a:first').focus(); // FIXME
+ }
+ }
+ });
+ break;
+ }
+ },
+ /**
+ * Triggers a remove event. I don't know why we stop the propagation of the
+ * event.
+ *
+ * @param {MouseEvent} event
+ */
+ _onRemoveIconClick: function (event) {
+ event.stopPropagation();
+ var $row = $(event.target).closest('tr');
+ var id = $row.data('id');
+ if ($row.hasClass('o_selected_row')) {
+ this.trigger_up('list_record_remove', {id: id});
+ } else {
+ var self = this;
+ this.unselectRow().then(function () {
+ self.trigger_up('list_record_remove', {id: id});
+ });
+ }
+ },
+ /**
+ * React to window resize events by recomputing the width of each column.
+ *
+ * @private
+ */
+ _onResize: function () {
+ this.columnWidths = false;
+ this._freezeColumnWidths();
+ },
+ /**
+ * If the list view editable, just let the event bubble. We don't want to
+ * open the record in this case anyway.
+ *
+ * @override
+ * @private
+ */
+ _onRowClicked: function (ev) {
+ if (!this._isRecordEditable(ev.currentTarget.dataset.id)) {
+ // If there is an edited record, tries to save it and do not open the clicked record
+ if (this.getEditableRecordID()) {
+ this.unselectRow();
+ } else {
+ this._super.apply(this, arguments);
+ }
+ }
+ },
+ /**
+ * Overrides to prevent from sorting if we are currently editing a record.
+ *
+ * @override
+ * @private
+ */
+ _onSortColumn: function () {
+ if (this.currentRow === null && !this.isResizing) {
+ this._super.apply(this, arguments);
+ }
+ },
+ /**
+ * Handles the resize feature on the column headers
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onStartResize: function (ev) {
+ // Only triggered by left mouse button
+ if (ev.which !== 1) {
+ return;
+ }
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ this.isResizing = true;
+
+ const table = this.el.getElementsByClassName('o_list_table')[0];
+ const th = ev.target.closest('th');
+ table.style.width = `${table.offsetWidth}px`;
+ const thPosition = [...th.parentNode.children].indexOf(th);
+ const resizingColumnElements = [...table.getElementsByTagName('tr')]
+ .filter(tr => tr.children.length === th.parentNode.children.length)
+ .map(tr => tr.children[thPosition]);
+ const optionalDropdown = this.el.getElementsByClassName('o_optional_columns')[0];
+ const initialX = ev.pageX;
+ const initialWidth = th.offsetWidth;
+ const initialTableWidth = table.offsetWidth;
+ const initialDropdownX = optionalDropdown ? optionalDropdown.offsetLeft : null;
+ const resizeStoppingEvents = [
+ 'keydown',
+ 'mousedown',
+ 'mouseup',
+ ];
+
+ // Fix container width to prevent the table from overflowing when being resized
+ if (!this.el.style.width) {
+ this.el.style.width = `${this.el.offsetWidth}px`;
+ }
+
+ // Apply classes to table and selected column
+ table.classList.add('o_resizing');
+ resizingColumnElements.forEach(el => el.classList.add('o_column_resizing'));
+
+ // Mousemove event : resize header
+ const resizeHeader = ev => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const delta = ev.pageX - initialX;
+ const newWidth = Math.max(10, initialWidth + delta);
+ const tableDelta = newWidth - initialWidth;
+ th.style.width = `${newWidth}px`;
+ th.style.maxWidth = `${newWidth}px`;
+ table.style.width = `${initialTableWidth + tableDelta}px`;
+ if (optionalDropdown) {
+ optionalDropdown.style.left = `${initialDropdownX + tableDelta}px`;
+ }
+ };
+ this._addEventListener('mousemove', window, resizeHeader);
+
+ // Mouse or keyboard events : stop resize
+ const stopResize = ev => {
+ // Ignores the initial 'left mouse button down' event in order
+ // to not instantly remove the listener
+ if (ev.type === 'mousedown' && ev.which === 1) {
+ return;
+ }
+ ev.preventDefault();
+ ev.stopPropagation();
+ // We need a small timeout to not trigger a click on column header
+ clearTimeout(this.resizeTimeout);
+ this.resizeTimeout = setTimeout(() => {
+ this.isResizing = false;
+ }, 100);
+ window.removeEventListener('mousemove', resizeHeader);
+ table.classList.remove('o_resizing');
+ resizingColumnElements.forEach(el => el.classList.remove('o_column_resizing'));
+ resizeStoppingEvents.forEach(stoppingEvent => {
+ window.removeEventListener(stoppingEvent, stopResize);
+ });
+
+ // we remove the focus to make sure that the there is no focus inside
+ // the tr. If that is the case, there is some css to darken the whole
+ // thead, and it looks quite weird with the small css hover effect.
+ document.activeElement.blur();
+ };
+ // We have to listen to several events to properly stop the resizing function. Those are:
+ // - mousedown (e.g. pressing right click)
+ // - mouseup : logical flow of the resizing feature (drag & drop)
+ // - keydown : (e.g. pressing 'Alt' + 'Tab' or 'Windows' key)
+ resizeStoppingEvents.forEach(stoppingEvent => {
+ this._addEventListener(stoppingEvent, window, stopResize);
+ });
+ },
+ /**
+ * Unselect the row before adding the optional column to the listview
+ *
+ * @override
+ * @private
+ */
+ _onToggleOptionalColumnDropdown: function (ev) {
+ this.unselectRow().then(this._super.bind(this, ev));
+ },
+ /**
+ * When a click happens outside the list view, or outside a currently
+ * selected row, we want to unselect it.
+ *
+ * This is quite tricky, because in many cases, such as an autocomplete
+ * dropdown opened by a many2one in a list editable row, we actually don't
+ * want to unselect (and save) the current row.
+ *
+ * So, we try to ignore clicks on subelements of the renderer that are
+ * appended in the body, outside the table)
+ *
+ * @param {MouseEvent} event
+ */
+ _onWindowClicked: function (event) {
+ // ignore clicks on readonly lists with no selected rows
+ if (!this.isEditable()) {
+ return;
+ }
+
+ // ignore clicks if this renderer is not in the dom.
+ if (!document.contains(this.el)) {
+ return;
+ }
+
+ // there is currently no selected row
+ if (this.currentRow === null) {
+ return;
+ }
+
+ // ignore clicks in autocomplete dropdowns
+ if ($(event.target).closest('.ui-autocomplete').length) {
+ return;
+ }
+
+ // ignore clicks if there is a modal, except if the list is in the last
+ // (active) modal
+ var $modal = $('body > .modal:last');
+ if ($modal.length) {
+ var $listModal = this.$el.closest('.modal');
+ if ($modal.prop('id') !== $listModal.prop('id')) {
+ return;
+ }
+ }
+
+ // ignore clicks if target is no longer in dom. For example, a click on
+ // the 'delete' trash icon of a m2m tag.
+ if (!document.contains(event.target)) {
+ return;
+ }
+
+ // ignore clicks if target is inside the list. In that case, they are
+ // handled directly by the renderer.
+ if (this.el.contains(event.target) && this.el !== event.target) {
+ return;
+ }
+
+ // ignore click if search facet is removed as it will re-render whole
+ // listview again
+ if ($(event.target).hasClass('o_facet_remove')) {
+ return;
+ }
+
+ this.unselectRow();
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/list/list_model.js b/addons/web/static/src/js/views/list/list_model.js
new file mode 100644
index 00000000..b119e7da
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_model.js
@@ -0,0 +1,175 @@
+odoo.define('web.ListModel', function (require) {
+ "use strict";
+
+ var BasicModel = require('web.BasicModel');
+
+ var ListModel = BasicModel.extend({
+
+ /**
+ * @override
+ * @param {Object} params.groupbys
+ */
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+
+ this.groupbys = params.groupbys;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * overridden to add `groupData` when performing get on list datapoints.
+ *
+ * @override
+ * @see _readGroupExtraFields
+ */
+ __get: function () {
+ var result = this._super.apply(this, arguments);
+ var dp = result && this.localData[result.id];
+ if (dp && dp.groupData) {
+ result.groupData = this.get(dp.groupData);
+ }
+ return result;
+ },
+ /**
+ * For a list of records, performs a write with all changes and fetches
+ * all data.
+ *
+ * @param {string} listDatapointId id of the parent list
+ * @param {string} referenceRecordId the record datapoint used to
+ * generate the changes to apply to recordIds
+ * @param {string[]} recordIds a list of record datapoint ids
+ * @param {string} fieldName the field to write
+ */
+ saveRecords: function (listDatapointId, referenceRecordId, recordIds, fieldName) {
+ var self = this;
+ var referenceRecord = this.localData[referenceRecordId];
+ var list = this.localData[listDatapointId];
+ // generate all record values to ensure that we'll write something
+ // (e.g. 2 records selected, edit a many2one in the first one, but
+ // reset same value, we still want to save this value on the other
+ // record)
+ var allChanges = this._generateChanges(referenceRecord, {changesOnly: false});
+ var changes = _.pick(allChanges, fieldName);
+ var records = recordIds.map(function (recordId) {
+ return self.localData[recordId];
+ });
+ var model = records[0].model;
+ var recordResIds = _.pluck(records, 'res_id');
+ var fieldNames = records[0].getFieldNames();
+ var context = records[0].getContext();
+
+ return this._rpc({
+ model: model,
+ method: 'write',
+ args: [recordResIds, changes],
+ context: context,
+ }).then(function () {
+ return self._rpc({
+ model: model,
+ method: 'read',
+ args: [recordResIds, fieldNames],
+ context: context,
+ });
+ }).then(function (results) {
+ results.forEach(function (data) {
+ var record = _.findWhere(records, {res_id: data.id});
+ record.data = _.extend({}, record.data, data);
+ record._changes = {};
+ record._isDirty = false;
+ self._parseServerData(fieldNames, record, record.data);
+ });
+ }).then(function () {
+ if (!list.groupedBy.length) {
+ return Promise.all([
+ self._fetchX2ManysBatched(list),
+ self._fetchReferencesBatched(list)
+ ]);
+ } else {
+ return Promise.all([
+ self._fetchX2ManysSingleBatch(list),
+ self._fetchReferencesSingleBatch(list)
+ ]);
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ *
+ * @override
+ * @private
+ */
+ _readGroup: function (list, options) {
+ var self = this;
+ options = options || {};
+ options.fetchRecordsWithGroups = true;
+ return this._super(list, options).then(function (result) {
+ return self._readGroupExtraFields(list).then(_.constant(result));
+ });
+ },
+ /**
+ * Fetches group specific fields on the group by relation and stores it
+ * in the column datapoint in a special key `groupData`.
+ * Data for the groups are fetched in batch for all groups, to avoid
+ * doing multiple calls.
+ * Note that the option is only for m2o fields.
+ *
+ * @private
+ * @param {Object} list
+ * @returns {Promise}
+ */
+ _readGroupExtraFields: function (list) {
+ var self = this;
+ var groupByFieldName = list.groupedBy[0].split(':')[0];
+ var groupedByField = list.fields[groupByFieldName];
+ if (groupedByField.type !== 'many2one' || !this.groupbys[groupByFieldName]) {
+ return Promise.resolve();
+ }
+ var groupIds = _.reduce(list.data, function (groupIds, id) {
+ var resId = self.get(id, { raw: true }).res_id;
+ if (resId) { // the field might be undefined when grouping
+ groupIds.push(resId);
+ }
+ return groupIds;
+ }, []);
+ var groupFields = Object.keys(this.groupbys[groupByFieldName].viewFields);
+ var prom;
+ if (groupIds.length && groupFields.length) {
+ prom = this._rpc({
+ model: groupedByField.relation,
+ method: 'read',
+ args: [groupIds, groupFields],
+ context: list.context,
+ });
+ }
+ return Promise.resolve(prom).then(function (result) {
+ var fvg = self.groupbys[groupByFieldName];
+ _.each(list.data, function (id) {
+ var dp = self.localData[id];
+ var groupData = result && _.findWhere(result, {
+ id: dp.res_id,
+ });
+ var groupDp = self._makeDataPoint({
+ context: dp.context,
+ data: groupData,
+ fields: fvg.fields,
+ fieldsInfo: fvg.fieldsInfo,
+ modelName: groupedByField.relation,
+ parentID: dp.id,
+ res_id: dp.res_id,
+ viewType: 'groupby',
+ });
+ dp.groupData = groupDp.id;
+ self._parseServerData(groupFields, groupDp, groupDp.data);
+ });
+ });
+ },
+ });
+ return ListModel;
+});
diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js
new file mode 100644
index 00000000..4e24ce54
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -0,0 +1,1470 @@
+odoo.define('web.ListRenderer', function (require) {
+"use strict";
+
+var BasicRenderer = require('web.BasicRenderer');
+const { ComponentWrapper } = require('web.OwlCompatibility');
+var config = require('web.config');
+var core = require('web.core');
+var dom = require('web.dom');
+var field_utils = require('web.field_utils');
+var Pager = require('web.Pager');
+var utils = require('web.utils');
+var viewUtils = require('web.viewUtils');
+
+var _t = core._t;
+
+// Allowed decoration on the list's rows: bold, italic and bootstrap semantics classes
+var DECORATIONS = [
+ 'decoration-bf',
+ 'decoration-it',
+ 'decoration-danger',
+ 'decoration-info',
+ 'decoration-muted',
+ 'decoration-primary',
+ 'decoration-success',
+ 'decoration-warning'
+];
+
+var FIELD_CLASSES = {
+ char: 'o_list_char',
+ float: 'o_list_number',
+ integer: 'o_list_number',
+ monetary: 'o_list_number',
+ text: 'o_list_text',
+ many2one: 'o_list_many2one',
+};
+
+var ListRenderer = BasicRenderer.extend({
+ className: 'o_list_view',
+ events: {
+ "mousedown": "_onMouseDown",
+ "click .o_optional_columns_dropdown .dropdown-item": "_onToggleOptionalColumn",
+ "click .o_optional_columns_dropdown_toggle": "_onToggleOptionalColumnDropdown",
+ 'click tbody tr': '_onRowClicked',
+ 'change tbody .o_list_record_selector': '_onSelectRecord',
+ 'click thead th.o_column_sortable': '_onSortColumn',
+ 'click .o_list_record_selector': '_onToggleCheckbox',
+ 'click .o_group_header': '_onToggleGroup',
+ 'change thead .o_list_record_selector input': '_onToggleSelection',
+ 'keypress thead tr td': '_onKeyPress',
+ 'keydown td': '_onKeyDown',
+ 'keydown th': '_onKeyDown',
+ },
+ sampleDataTargets: [
+ '.o_data_row',
+ '.o_group_header',
+ '.o_list_table > tfoot',
+ '.o_list_table > thead .o_list_record_selector',
+ ],
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {any} state
+ * @param {Object} params
+ * @param {boolean} params.hasSelectors
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this._preprocessColumns();
+ this.columnInvisibleFields = params.columnInvisibleFields || {};
+ this.rowDecorations = this._extractDecorationAttrs(this.arch);
+ this.fieldDecorations = {};
+ for (const field of this.arch.children.filter(c => c.tag === 'field')) {
+ const decorations = this._extractDecorationAttrs(field);
+ this.fieldDecorations[field.attrs.name] = decorations;
+ }
+ this.hasSelectors = params.hasSelectors;
+ this.selection = params.selectedRecords || [];
+ this.pagers = []; // instantiated pagers (only for grouped lists)
+ this.isGrouped = this.state.groupedBy.length > 0;
+ this.groupbys = params.groupbys;
+ },
+ /**
+ * Compute columns visilibity. This can't be done earlier as we need the
+ * controller to respond to the load_optional_fields call of processColumns.
+ *
+ * @override
+ */
+ willStart: function () {
+ this._processColumns(this.columnInvisibleFields);
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Order to focus to be given to the content of the current view
+ *
+ * @override
+ */
+ giveFocus: function () {
+ this.$('th:eq(0) input, th:eq(1)').first().focus();
+ },
+ /**
+ * @override
+ */
+ updateState: function (state, params) {
+ this._setState(state);
+ this.isGrouped = this.state.groupedBy.length > 0;
+ this.columnInvisibleFields = params.columnInvisibleFields || {};
+ this._processColumns(this.columnInvisibleFields);
+ if (params.selectedRecords) {
+ this.selection = params.selectedRecords;
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method does a in-memory computation of the aggregate values, for
+ * each columns that corresponds to a numeric field with a proper aggregate
+ * function.
+ *
+ * The result of these computations is stored in the 'aggregate' key of each
+ * column of this.columns. This will be then used by the _renderFooter
+ * method to display the appropriate amount.
+ *
+ * @private
+ */
+ _computeAggregates: function () {
+ var self = this;
+ var data = [];
+ if (this.selection.length) {
+ utils.traverse_records(this.state, function (record) {
+ if (_.contains(self.selection, record.id)) {
+ data.push(record); // find selected records
+ }
+ });
+ } else {
+ data = this.state.data;
+ }
+
+ _.each(this.columns, this._computeColumnAggregates.bind(this, data));
+ },
+ /**
+ * Compute the aggregate values for a given column and a set of records.
+ * The aggregate values are then written, if applicable, in the 'aggregate'
+ * key of the column object.
+ *
+ * @private
+ * @param {Object[]} data a list of selected/all records
+ * @param {Object} column
+ */
+ _computeColumnAggregates: function (data, column) {
+ var attrs = column.attrs;
+ var field = this.state.fields[attrs.name];
+ if (!field) {
+ return;
+ }
+ var type = field.type;
+ if (type !== 'integer' && type !== 'float' && type !== 'monetary') {
+ return;
+ }
+ var func = (attrs.sum && 'sum') || (attrs.avg && 'avg') ||
+ (attrs.max && 'max') || (attrs.min && 'min');
+ if (func) {
+ var count = 0;
+ var aggregateValue = 0;
+ if (func === 'max') {
+ aggregateValue = -Infinity;
+ } else if (func === 'min') {
+ aggregateValue = Infinity;
+ }
+ _.each(data, function (d) {
+ count += 1;
+ var value = (d.type === 'record') ? d.data[attrs.name] : d.aggregateValues[attrs.name];
+ if (func === 'avg') {
+ aggregateValue += value;
+ } else if (func === 'sum') {
+ aggregateValue += value;
+ } else if (func === 'max') {
+ aggregateValue = Math.max(aggregateValue, value);
+ } else if (func === 'min') {
+ aggregateValue = Math.min(aggregateValue, value);
+ }
+ });
+ if (func === 'avg') {
+ aggregateValue = count ? aggregateValue / count : aggregateValue;
+ }
+ column.aggregate = {
+ help: attrs[func],
+ value: aggregateValue,
+ };
+ }
+ },
+ /**
+ * Extract the decoration attributes (e.g. decoration-danger) of a node. The
+ * condition is processed such that it is ready to be evaluated.
+ *
+ * @private
+ * @param {Object} node the <tree> or a <field> node
+ * @returns {Object}
+ */
+ _extractDecorationAttrs: function (node) {
+ const decorations = {};
+ for (const [key, expr] of Object.entries(node.attrs)) {
+ if (DECORATIONS.includes(key)) {
+ const cssClass = key.replace('decoration', 'text');
+ decorations[cssClass] = py.parse(py.tokenize(expr));
+ }
+ }
+ return decorations;
+ },
+ /**
+ *
+ * @private
+ * @param {jQuery} $cell
+ * @param {string} direction
+ * @param {integer} colIndex
+ * @returns {jQuery|null}
+ */
+ _findConnectedCell: function ($cell, direction, colIndex) {
+ var $connectedRow = $cell.closest('tr')[direction]('tr');
+
+ if (!$connectedRow.length) {
+ // Is there another group ? Look at our parent's sibling
+ // We can have th in tbody so we can't simply look for thead
+ // if cell is a th and tbody instead
+ var tbody = $cell.closest('tbody, thead');
+ var $connectedGroup = tbody[direction]('tbody, thead');
+ if ($connectedGroup.length) {
+ // Found another group
+ var $connectedRows = $connectedGroup.find('tr');
+ var rowIndex;
+ if (direction === 'prev') {
+ rowIndex = $connectedRows.length - 1;
+ } else {
+ rowIndex = 0;
+ }
+ $connectedRow = $connectedRows.eq(rowIndex);
+ } else {
+ // End of the table
+ return;
+ }
+ }
+
+ var $connectedCell;
+ if ($connectedRow.hasClass('o_group_header')) {
+ $connectedCell = $connectedRow.children();
+ this.currentColIndex = colIndex;
+ } else if ($connectedRow.has('td.o_group_field_row_add').length) {
+ $connectedCell = $connectedRow.find('.o_group_field_row_add');
+ this.currentColIndex = colIndex;
+ } else {
+ var connectedRowChildren = $connectedRow.children();
+ if (colIndex === -1) {
+ colIndex = connectedRowChildren.length - 1;
+ }
+ $connectedCell = connectedRowChildren.eq(colIndex);
+ }
+
+ return $connectedCell;
+ },
+ /**
+ * return the number of visible columns. Note that this number depends on
+ * the state of the renderer. For example, in editable mode, it could be
+ * one more that in non editable mode, because there may be a visible 'trash
+ * icon'.
+ *
+ * @private
+ * @returns {integer}
+ */
+ _getNumberOfCols: function () {
+ var n = this.columns.length;
+ return this.hasSelectors ? n + 1 : n;
+ },
+ /**
+ * Returns the local storage key for stored enabled optional columns
+ *
+ * @private
+ * @returns {string}
+ */
+ _getOptionalColumnsStorageKeyParts: function () {
+ var self = this;
+ return {
+ fields: _.map(this.state.fieldsInfo[this.viewType], function (_, fieldName) {
+ return {name: fieldName, type: self.state.fields[fieldName].type};
+ }),
+ };
+ },
+ /**
+ * Adjacent buttons (in the arch) are displayed in a single column. This
+ * function iterates over the arch's nodes and replaces "button" nodes by
+ * "button_group" nodes, with a single "button_group" node for adjacent
+ * "button" nodes. A "button_group" node has a "children" attribute
+ * containing all "button" nodes in the group.
+ *
+ * @private
+ */
+ _groupAdjacentButtons: function () {
+ const children = [];
+ let groupId = 0;
+ let buttonGroupNode = null;
+ for (const c of this.arch.children) {
+ if (c.tag === 'button') {
+ if (!buttonGroupNode) {
+ buttonGroupNode = {
+ tag: 'button_group',
+ children: [c],
+ attrs: {
+ name: `button_group_${groupId++}`,
+ modifiers: {},
+ },
+ };
+ children.push(buttonGroupNode);
+ } else {
+ buttonGroupNode.children.push(c);
+ }
+ } else {
+ buttonGroupNode = null;
+ children.push(c);
+ }
+ }
+ this.arch.children = children;
+ },
+ /**
+ * Processes arch's child nodes for the needs of the list view:
+ * - detects oe_read_only/oe_edit_only classnames
+ * - groups adjacent buttons in a single column.
+ * This function is executed only once, at initialization.
+ *
+ * @private
+ */
+ _preprocessColumns: function () {
+ this._processModeClassNames();
+ this._groupAdjacentButtons();
+
+ // set as readOnly (resp. editOnly) button groups containing only
+ // readOnly (resp. editOnly) buttons, s.t. no column is rendered
+ this.arch.children.filter(c => c.tag === 'button_group').forEach(c => {
+ c.attrs.editOnly = c.children.every(n => n.attrs.editOnly);
+ c.attrs.readOnly = c.children.every(n => n.attrs.readOnly);
+ });
+ },
+ /**
+ * Removes the columns which should be invisible. This function is executed
+ * at each (re-)rendering of the list.
+ *
+ * @param {Object} columnInvisibleFields contains the column invisible modifier values
+ */
+ _processColumns: function (columnInvisibleFields) {
+ var self = this;
+ this.handleField = null;
+ this.columns = [];
+ this.optionalColumns = [];
+ this.optionalColumnsEnabled = [];
+ var storedOptionalColumns;
+ this.trigger_up('load_optional_fields', {
+ keyParts: this._getOptionalColumnsStorageKeyParts(),
+ callback: function (res) {
+ storedOptionalColumns = res;
+ },
+ });
+ _.each(this.arch.children, function (c) {
+ if (c.tag !== 'control' && c.tag !== 'groupby' && c.tag !== 'header') {
+ var reject = c.attrs.modifiers.column_invisible;
+ // If there is an evaluated domain for the field we override the node
+ // attribute to have the evaluated modifier value.
+ if (c.tag === "button_group") {
+ // FIXME: 'column_invisible' attribute is available for fields *and* buttons,
+ // so 'columnInvisibleFields' variable name is misleading, it should be renamed
+ reject = c.children.every(child => columnInvisibleFields[child.attrs.name]);
+ } else if (c.attrs.name in columnInvisibleFields) {
+ reject = columnInvisibleFields[c.attrs.name];
+ }
+ if (!reject && c.attrs.widget === 'handle') {
+ self.handleField = c.attrs.name;
+ if (self.isGrouped) {
+ reject = true;
+ }
+ }
+
+ if (!reject && c.attrs.optional) {
+ self.optionalColumns.push(c);
+ var enabled;
+ if (storedOptionalColumns === undefined) {
+ enabled = c.attrs.optional === 'show';
+ } else {
+ enabled = _.contains(storedOptionalColumns, c.attrs.name);
+ }
+ if (enabled) {
+ self.optionalColumnsEnabled.push(c.attrs.name);
+ }
+ reject = !enabled;
+ }
+
+ if (!reject) {
+ self.columns.push(c);
+ }
+ }
+ });
+ },
+ /**
+ * Classnames "oe_edit_only" and "oe_read_only" aim to only display the cell
+ * in the corresponding mode. This only concerns lists inside form views
+ * (for x2many fields). This function detects the className and stores a
+ * flag on the node's attrs accordingly, to ease further computations.
+ *
+ * @private
+ */
+ _processModeClassNames: function () {
+ this.arch.children.forEach(c => {
+ if (c.attrs.class) {
+ c.attrs.editOnly = /\boe_edit_only\b/.test(c.attrs.class);
+ c.attrs.readOnly = /\boe_read_only\b/.test(c.attrs.class);
+ }
+ });
+ },
+ /**
+ * Render a list of <td>, with aggregates if available. It can be displayed
+ * in the footer, or for each open groups.
+ *
+ * @private
+ * @param {any} aggregateValues
+ * @returns {jQueryElement[]} a list of <td> with the aggregate values
+ */
+ _renderAggregateCells: function (aggregateValues) {
+ var self = this;
+
+ return _.map(this.columns, function (column) {
+ var $cell = $('<td>');
+ if (config.isDebug()) {
+ $cell.addClass(column.attrs.name);
+ }
+ if (column.attrs.editOnly) {
+ $cell.addClass('oe_edit_only');
+ }
+ if (column.attrs.readOnly) {
+ $cell.addClass('oe_read_only');
+ }
+ if (column.attrs.name in aggregateValues) {
+ var field = self.state.fields[column.attrs.name];
+ var value = aggregateValues[column.attrs.name].value;
+ var help = aggregateValues[column.attrs.name].help;
+ var formatFunc = field_utils.format[column.attrs.widget];
+ if (!formatFunc) {
+ formatFunc = field_utils.format[field.type];
+ }
+ var formattedValue = formatFunc(value, field, {
+ escape: true,
+ digits: column.attrs.digits ? JSON.parse(column.attrs.digits) : undefined,
+ });
+ $cell.addClass('o_list_number').attr('title', help).html(formattedValue);
+ }
+ return $cell;
+ });
+ },
+ /**
+ * Render the main body of the table, with all its content. Note that it
+ * has been decided to always render at least 4 rows, even if we have less
+ * data. The reason is that lists with 0 or 1 lines don't really look like
+ * a table.
+ *
+ * @private
+ * @returns {jQueryElement} a jquery element <tbody>
+ */
+ _renderBody: function () {
+ var self = this;
+ var $rows = this._renderRows();
+ while ($rows.length < 4) {
+ $rows.push(self._renderEmptyRow());
+ }
+ return $('<tbody>').append($rows);
+ },
+ /**
+ * Render a cell for the table. For most cells, we only want to display the
+ * formatted value, with some appropriate css class. However, when the
+ * node was explicitely defined with a 'widget' attribute, then we
+ * instantiate the corresponding widget.
+ *
+ * @private
+ * @param {Object} record
+ * @param {Object} node
+ * @param {integer} colIndex
+ * @param {Object} [options]
+ * @param {Object} [options.mode]
+ * @param {Object} [options.renderInvisible=false]
+ * force the rendering of invisible cell content
+ * @param {Object} [options.renderWidgets=false]
+ * force the rendering of the cell value thanks to a widget
+ * @returns {jQueryElement} a <td> element
+ */
+ _renderBodyCell: function (record, node, colIndex, options) {
+ var tdClassName = 'o_data_cell';
+ if (node.tag === 'button_group') {
+ tdClassName += ' o_list_button';
+ } else if (node.tag === 'field') {
+ tdClassName += ' o_field_cell';
+ var typeClass = FIELD_CLASSES[this.state.fields[node.attrs.name].type];
+ if (typeClass) {
+ tdClassName += (' ' + typeClass);
+ }
+ if (node.attrs.widget) {
+ tdClassName += (' o_' + node.attrs.widget + '_cell');
+ }
+ }
+ if (node.attrs.editOnly) {
+ tdClassName += ' oe_edit_only';
+ }
+ if (node.attrs.readOnly) {
+ tdClassName += ' oe_read_only';
+ }
+ var $td = $('<td>', { class: tdClassName, tabindex: -1 });
+
+ // We register modifiers on the <td> element so that it gets the correct
+ // modifiers classes (for styling)
+ var modifiers = this._registerModifiers(node, record, $td, _.pick(options, 'mode'));
+ // If the invisible modifiers is true, the <td> element is left empty.
+ // Indeed, if the modifiers was to change the whole cell would be
+ // rerendered anyway.
+ if (modifiers.invisible && !(options && options.renderInvisible)) {
+ return $td;
+ }
+
+ if (node.tag === 'button_group') {
+ for (const buttonNode of node.children) {
+ if (!this.columnInvisibleFields[buttonNode.attrs.name]) {
+ $td.append(this._renderButton(record, buttonNode));
+ }
+ }
+ return $td;
+ } else if (node.tag === 'widget') {
+ return $td.append(this._renderWidget(record, node));
+ }
+ if (node.attrs.widget || (options && options.renderWidgets)) {
+ var $el = this._renderFieldWidget(node, record, _.pick(options, 'mode'));
+ return $td.append($el);
+ }
+ this._handleAttributes($td, node);
+ this._setDecorationClasses($td, this.fieldDecorations[node.attrs.name], record);
+
+ var name = node.attrs.name;
+ var field = this.state.fields[name];
+ var value = record.data[name];
+ var formatter = field_utils.format[field.type];
+ var formatOptions = {
+ escape: true,
+ data: record.data,
+ isPassword: 'password' in node.attrs,
+ digits: node.attrs.digits && JSON.parse(node.attrs.digits),
+ };
+ var formattedValue = formatter(value, field, formatOptions);
+ var title = '';
+ if (field.type !== 'boolean') {
+ title = formatter(value, field, _.extend(formatOptions, {escape: false}));
+ }
+ return $td.html(formattedValue).attr('title', title);
+ },
+ /**
+ * Renders the button element associated to the given node and record.
+ *
+ * @private
+ * @param {Object} record
+ * @param {Object} node
+ * @returns {jQuery} a <button> element
+ */
+ _renderButton: function (record, node) {
+ var self = this;
+ var nodeWithoutWidth = Object.assign({}, node);
+ delete nodeWithoutWidth.attrs.width;
+
+ let extraClass = '';
+ if (node.attrs.icon) {
+ // if there is an icon, we force the btn-link style, unless a btn-xxx
+ // style class is explicitely provided
+ const btnStyleRegex = /\bbtn-[a-z]+\b/;
+ if (!btnStyleRegex.test(nodeWithoutWidth.attrs.class)) {
+ extraClass = 'btn-link o_icon_button';
+ }
+ }
+ var $button = viewUtils.renderButtonFromNode(nodeWithoutWidth, {
+ extraClass: extraClass,
+ });
+ this._handleAttributes($button, node);
+ this._registerModifiers(node, record, $button);
+
+ if (record.res_id) {
+ // TODO this should be moved to a handler
+ $button.on("click", function (e) {
+ e.stopPropagation();
+ self.trigger_up('button_clicked', {
+ attrs: node.attrs,
+ record: record,
+ });
+ });
+ } else {
+ if (node.attrs.options.warn) {
+ $button.on("click", function (e) {
+ e.stopPropagation();
+ self.do_warn(false, _t('Please click on the "save" button first'));
+ });
+ } else {
+ $button.prop('disabled', true);
+ }
+ }
+
+ return $button;
+ },
+ /**
+ * Render a complete empty row. This is used to fill in the blanks when we
+ * have less than 4 lines to display.
+ *
+ * @private
+ * @returns {jQueryElement} a <tr> element
+ */
+ _renderEmptyRow: function () {
+ var $td = $('<td>&nbsp;</td>').attr('colspan', this._getNumberOfCols());
+ return $('<tr>').append($td);
+ },
+ /**
+ * Render the footer. It is a <tfoot> with a single row, containing all
+ * aggregates, if applicable.
+ *
+ * @private
+ * @returns {jQueryElement} a <tfoot> element
+ */
+ _renderFooter: function () {
+ var aggregates = {};
+ _.each(this.columns, function (column) {
+ if ('aggregate' in column) {
+ aggregates[column.attrs.name] = column.aggregate;
+ }
+ });
+ var $cells = this._renderAggregateCells(aggregates);
+ if (this.hasSelectors) {
+ $cells.unshift($('<td>'));
+ }
+ return $('<tfoot>').append($('<tr>').append($cells));
+ },
+ /**
+ * Renders the group button element.
+ *
+ * @private
+ * @param {Object} record
+ * @param {Object} group
+ * @returns {jQuery} a <button> element
+ */
+ _renderGroupButton: function (list, node) {
+ var $button = viewUtils.renderButtonFromNode(node, {
+ extraClass: node.attrs.icon ? 'o_icon_button' : undefined,
+ textAsTitle: !!node.attrs.icon,
+ });
+ this._handleAttributes($button, node);
+ this._registerModifiers(node, list.groupData, $button);
+
+ // TODO this should be moved to event handlers
+ $button.on("click", this._onGroupButtonClicked.bind(this, list.groupData, node));
+ $button.on("keydown", this._onGroupButtonKeydown.bind(this));
+
+ return $button;
+ },
+ /**
+ * Renders the group buttons.
+ *
+ * @private
+ * @param {Object} record
+ * @param {Object} group
+ * @returns {jQuery} a <button> element
+ */
+ _renderGroupButtons: function (list, group) {
+ var self = this;
+ var $buttons = $();
+ if (list.value) {
+ // buttons make no sense for 'Undefined' group
+ group.arch.children.forEach(function (child) {
+ if (child.tag === 'button') {
+ $buttons = $buttons.add(self._renderGroupButton(list, child));
+ }
+ });
+ }
+ return $buttons;
+ },
+ /**
+ * Render the row that represent a group
+ *
+ * @private
+ * @param {Object} group
+ * @param {integer} groupLevel the nesting level (0 for root groups)
+ * @returns {jQueryElement} a <tr> element
+ */
+ _renderGroupRow: function (group, groupLevel) {
+ var cells = [];
+
+ var name = group.value === undefined ? _t('Undefined') : group.value;
+ var groupBy = this.state.groupedBy[groupLevel];
+ if (group.fields[groupBy.split(':')[0]].type !== 'boolean') {
+ name = name || _t('Undefined');
+ }
+ var $th = $('<th>')
+ .addClass('o_group_name')
+ .attr('tabindex', -1)
+ .text(name + ' (' + group.count + ')');
+ var $arrow = $('<span>')
+ .css('padding-left', 2 + (groupLevel * 20) + 'px')
+ .css('padding-right', '5px')
+ .addClass('fa');
+ if (group.count > 0) {
+ $arrow.toggleClass('fa-caret-right', !group.isOpen)
+ .toggleClass('fa-caret-down', group.isOpen);
+ }
+ $th.prepend($arrow);
+ cells.push($th);
+
+ var aggregateKeys = Object.keys(group.aggregateValues);
+ var aggregateValues = _.mapObject(group.aggregateValues, function (value) {
+ return { value: value };
+ });
+ var aggregateCells = this._renderAggregateCells(aggregateValues);
+ var firstAggregateIndex = _.findIndex(this.columns, function (column) {
+ return column.tag === 'field' && _.contains(aggregateKeys, column.attrs.name);
+ });
+ var colspanBeforeAggregate;
+ if (firstAggregateIndex !== -1) {
+ // if there are aggregates, the first $th goes until the first
+ // aggregate then all cells between aggregates are rendered
+ colspanBeforeAggregate = firstAggregateIndex;
+ var lastAggregateIndex = _.findLastIndex(this.columns, function (column) {
+ return column.tag === 'field' && _.contains(aggregateKeys, column.attrs.name);
+ });
+ cells = cells.concat(aggregateCells.slice(firstAggregateIndex, lastAggregateIndex + 1));
+ var colSpan = this.columns.length - 1 - lastAggregateIndex;
+ if (colSpan > 0) {
+ cells.push($('<th>').attr('colspan', colSpan));
+ }
+ } else {
+ var colN = this.columns.length;
+ colspanBeforeAggregate = colN > 1 ? colN - 1 : 1;
+ if (colN > 1) {
+ cells.push($('<th>'));
+ }
+ }
+ if (this.hasSelectors) {
+ colspanBeforeAggregate += 1;
+ }
+ $th.attr('colspan', colspanBeforeAggregate);
+
+ if (group.isOpen && !group.groupedBy.length && (group.count > group.data.length)) {
+ const lastCell = cells[cells.length - 1][0];
+ this._renderGroupPager(group, lastCell);
+ }
+ if (group.isOpen && this.groupbys[groupBy]) {
+ var $buttons = this._renderGroupButtons(group, this.groupbys[groupBy]);
+ if ($buttons.length) {
+ var $buttonSection = $('<div>', {
+ class: 'o_group_buttons',
+ }).append($buttons);
+ $th.append($buttonSection);
+ }
+ }
+ return $('<tr>')
+ .addClass('o_group_header')
+ .toggleClass('o_group_open', group.isOpen)
+ .toggleClass('o_group_has_content', group.count > 0)
+ .data('group', group)
+ .append(cells);
+ },
+ /**
+ * Render the content of a given opened group.
+ *
+ * @private
+ * @param {Object} group
+ * @param {integer} groupLevel the nesting level (0 for root groups)
+ * @returns {jQueryElement} a <tr> element
+ */
+ _renderGroup: function (group, groupLevel) {
+ var self = this;
+ if (group.groupedBy.length) {
+ // the opened group contains subgroups
+ return this._renderGroups(group.data, groupLevel + 1);
+ } else {
+ // the opened group contains records
+ var $records = _.map(group.data, function (record) {
+ return self._renderRow(record);
+ });
+ return [$('<tbody>').append($records)];
+ }
+ },
+ /**
+ * Renders the pager for a given group
+ *
+ * @private
+ * @param {Object} group
+ * @param {HTMLElement} target
+ */
+ _renderGroupPager: function (group, target) {
+ const currentMinimum = group.offset + 1;
+ const limit = group.limit;
+ const size = group.count;
+ if (!this._shouldRenderPager(currentMinimum, limit, size)) {
+ return;
+ }
+ const pager = new ComponentWrapper(this, Pager, { currentMinimum, limit, size });
+ const pagerMounting = pager.mount(target).then(() => {
+ // Event binding is done here to get the related group and wrapper.
+ pager.el.addEventListener('pager-changed', ev => this._onPagerChanged(ev, group));
+ // Prevent pager clicks to toggle the group.
+ pager.el.addEventListener('click', ev => ev.stopPropagation());
+ });
+ this.defs.push(pagerMounting);
+ this.pagers.push(pager);
+ },
+ /**
+ * Render all groups in the view. We assume that the view is in grouped
+ * mode.
+ *
+ * Note that each group is rendered inside a <tbody>, which contains a group
+ * row, then possibly a bunch of rows for each record.
+ *
+ * @private
+ * @param {Object} data the dataPoint containing the groups
+ * @param {integer} [groupLevel=0] the nesting level. 0 is for the root group
+ * @returns {jQueryElement[]} a list of <tbody>
+ */
+ _renderGroups: function (data, groupLevel) {
+ var self = this;
+ groupLevel = groupLevel || 0;
+ var result = [];
+ var $tbody = $('<tbody>');
+ _.each(data, function (group) {
+ if (!$tbody) {
+ $tbody = $('<tbody>');
+ }
+ $tbody.append(self._renderGroupRow(group, groupLevel));
+ if (group.data.length) {
+ result.push($tbody);
+ result = result.concat(self._renderGroup(group, groupLevel));
+ $tbody = null;
+ }
+ });
+ if ($tbody) {
+ result.push($tbody);
+ }
+ return result;
+ },
+ /**
+ * Render the main header for the list view. It is basically just a <thead>
+ * with the name of each fields
+ *
+ * @private
+ * @returns {jQueryElement} a <thead> element
+ */
+ _renderHeader: function () {
+ var $tr = $('<tr>')
+ .append(_.map(this.columns, this._renderHeaderCell.bind(this)));
+ if (this.hasSelectors) {
+ $tr.prepend(this._renderSelector('th'));
+ }
+ return $('<thead>').append($tr);
+ },
+ /**
+ * Render a single <th> with the informations for a column. If it is not a
+ * field or nolabel attribute is set to "1", the th will be empty.
+ * Otherwise, it will contains all relevant information for the field.
+ *
+ * @private
+ * @param {Object} node
+ * @returns {jQueryElement} a <th> element
+ */
+ _renderHeaderCell: function (node) {
+ const { icon, name, string } = node.attrs;
+ var order = this.state.orderedBy;
+ var isNodeSorted = order[0] && order[0].name === name;
+ var field = this.state.fields[name];
+ var $th = $('<th>');
+ if (name) {
+ $th.attr('data-name', name);
+ } else if (string) {
+ $th.attr('data-string', string);
+ } else if (icon) {
+ $th.attr('data-icon', icon);
+ }
+ if (node.attrs.editOnly) {
+ $th.addClass('oe_edit_only');
+ }
+ if (node.attrs.readOnly) {
+ $th.addClass('oe_read_only');
+ }
+ if (node.tag === 'button_group') {
+ $th.addClass('o_list_button');
+ }
+ if (!field || node.attrs.nolabel === '1') {
+ return $th;
+ }
+ var description = string || field.string;
+ if (node.attrs.widget) {
+ $th.addClass(' o_' + node.attrs.widget + '_cell');
+ const FieldWidget = this.state.fieldsInfo.list[name].Widget;
+ if (FieldWidget.prototype.noLabel) {
+ description = '';
+ } else if (FieldWidget.prototype.label) {
+ description = FieldWidget.prototype.label;
+ }
+ }
+ $th.text(description)
+ .attr('tabindex', -1)
+ .toggleClass('o-sort-down', isNodeSorted ? !order[0].asc : false)
+ .toggleClass('o-sort-up', isNodeSorted ? order[0].asc : false)
+ .addClass((field.sortable || this.state.fieldsInfo.list[name].options.allow_order || false) && 'o_column_sortable');
+
+ if (isNodeSorted) {
+ $th.attr('aria-sort', order[0].asc ? 'ascending' : 'descending');
+ }
+
+ if (field.type === 'float' || field.type === 'integer' || field.type === 'monetary') {
+ $th.addClass('o_list_number_th');
+ }
+
+ if (config.isDebug()) {
+ var fieldDescr = {
+ field: field,
+ name: name,
+ string: description || name,
+ record: this.state,
+ attrs: _.extend({}, node.attrs, this.state.fieldsInfo.list[name]),
+ };
+ this._addFieldTooltip(fieldDescr, $th);
+ } else {
+ $th.attr('title', description);
+ }
+ return $th;
+ },
+ /**
+ * Render a row, corresponding to a record.
+ *
+ * @private
+ * @param {Object} record
+ * @returns {jQueryElement} a <tr> element
+ */
+ _renderRow: function (record) {
+ var self = this;
+ var $cells = this.columns.map(function (node, index) {
+ return self._renderBodyCell(record, node, index, { mode: 'readonly' });
+ });
+
+ var $tr = $('<tr/>', { class: 'o_data_row' })
+ .attr('data-id', record.id)
+ .append($cells);
+ if (this.hasSelectors) {
+ $tr.prepend(this._renderSelector('td', !record.res_id));
+ }
+ this._setDecorationClasses($tr, this.rowDecorations, record);
+ return $tr;
+ },
+ /**
+ * Render all rows. This method should only called when the view is not
+ * grouped.
+ *
+ * @private
+ * @returns {jQueryElement[]} a list of <tr>
+ */
+ _renderRows: function () {
+ return this.state.data.map(this._renderRow.bind(this));
+ },
+ /**
+ * Render a single <th> with dropdown menu to display optional columns of view.
+ *
+ * @private
+ * @returns {jQueryElement} a <th> element
+ */
+ _renderOptionalColumnsDropdown: function () {
+ var self = this;
+ var $optionalColumnsDropdown = $('<div>', {
+ class: 'o_optional_columns text-center dropdown',
+ });
+ var $a = $("<a>", {
+ 'class': "dropdown-toggle text-dark o-no-caret",
+ 'href': "#",
+ 'role': "button",
+ 'data-toggle': "dropdown",
+ 'data-display': "static",
+ 'aria-expanded': false,
+ 'aria-label': _t('Optional columns'),
+ });
+ $a.appendTo($optionalColumnsDropdown);
+
+ // Set the expansion direction of the dropdown
+ // The button is located at the end of the list headers
+ // We want the dropdown to expand towards the list rather than away from it
+ // https://getbootstrap.com/docs/4.0/components/dropdowns/#menu-alignment
+ var direction = _t.database.parameters.direction;
+ var dropdownMenuClass = direction === 'rtl' ? 'dropdown-menu-left' : 'dropdown-menu-right';
+ var $dropdown = $("<div>", {
+ class: 'dropdown-menu o_optional_columns_dropdown ' + dropdownMenuClass,
+ role: 'menu',
+ });
+ this.optionalColumns.forEach(function (col) {
+ var txt = (col.attrs.string || self.state.fields[col.attrs.name].string) +
+ (config.isDebug() ? (' (' + col.attrs.name + ')') : '');
+ var $checkbox = dom.renderCheckbox({
+ text: txt,
+ role: "menuitemcheckbox",
+ prop: {
+ name: col.attrs.name,
+ checked: _.contains(self.optionalColumnsEnabled, col.attrs.name),
+ }
+ });
+ $dropdown.append($("<div>", {
+ class: "dropdown-item",
+ }).append($checkbox));
+ });
+ $dropdown.appendTo($optionalColumnsDropdown);
+ return $optionalColumnsDropdown;
+ },
+ /**
+ * A 'selector' is the small checkbox on the left of a record in a list
+ * view. This is rendered as an input inside a div, so we can properly
+ * style it.
+ *
+ * Note that it takes a tag in argument, because selectors in the header
+ * are renderd in a th, and those in the tbody are in a td.
+ *
+ * @private
+ * @param {string} tag either th or td
+ * @param {boolean} disableInput if true, the input generated will be disabled
+ * @returns {jQueryElement}
+ */
+ _renderSelector: function (tag, disableInput) {
+ var $content = dom.renderCheckbox();
+ if (disableInput) {
+ $content.find("input[type='checkbox']").prop('disabled', disableInput);
+ }
+ return $('<' + tag + '>')
+ .addClass('o_list_record_selector')
+ .append($content);
+ },
+ /**
+ * Main render function for the list. It is rendered as a table. For now,
+ * this method does not wait for the field widgets to be ready.
+ *
+ * @override
+ * @returns {Promise} resolved when the view has been rendered
+ */
+ async _renderView() {
+ const oldPagers = this.pagers;
+ let prom;
+ let tableWrapper;
+ if (this.state.count > 0 || !this.noContentHelp) {
+ // render a table if there are records, or if there is no no content
+ // helper (empty table in this case)
+ this.pagers = [];
+
+ const orderedBy = this.state.orderedBy;
+ this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField;
+ this._computeAggregates();
+
+ const $table = $(
+ '<table class="o_list_table table table-sm table-hover table-striped"/>'
+ );
+ $table.toggleClass('o_list_table_grouped', this.isGrouped);
+ $table.toggleClass('o_list_table_ungrouped', !this.isGrouped);
+ const defs = [];
+ this.defs = defs;
+ if (this.isGrouped) {
+ $table.append(this._renderHeader());
+ $table.append(this._renderGroups(this.state.data));
+ $table.append(this._renderFooter());
+
+ } else {
+ $table.append(this._renderHeader());
+ $table.append(this._renderBody());
+ $table.append(this._renderFooter());
+ }
+ tableWrapper = Object.assign(document.createElement('div'), {
+ className: 'table-responsive',
+ });
+ tableWrapper.appendChild($table[0]);
+ delete this.defs;
+ prom = Promise.all(defs);
+ }
+
+ await Promise.all([this._super.apply(this, arguments), prom]);
+
+ this.el.innerHTML = "";
+ this.el.classList.remove('o_list_optional_columns');
+
+ // destroy the previously instantiated pagers, if any
+ oldPagers.forEach(pager => pager.destroy());
+
+ // append the table (if any) to the main element
+ if (tableWrapper) {
+ this.el.appendChild(tableWrapper);
+ if (document.body.contains(this.el)) {
+ this.pagers.forEach(pager => pager.on_attach_callback());
+ }
+ if (this.optionalColumns.length) {
+ this.el.classList.add('o_list_optional_columns');
+ this.$('table').append(
+ $('<i class="o_optional_columns_dropdown_toggle fa fa-ellipsis-v"/>')
+ );
+ this.$el.append(this._renderOptionalColumnsDropdown());
+ }
+ if (this.selection.length) {
+ const $checked_rows = this.$('tr').filter(
+ (i, el) => this.selection.includes(el.dataset.id)
+ );
+ $checked_rows.find('.o_list_record_selector input').prop('checked', true);
+ if ($checked_rows.length === this.$('.o_data_row').length) { // all rows are checked
+ this.$('thead .o_list_record_selector input').prop('checked', true);
+ }
+ }
+ }
+
+ // display the no content helper if necessary
+ if (!this._hasContent() && !!this.noContentHelp) {
+ this._renderNoContentHelper();
+ }
+ },
+ /**
+ * Each line or cell can be decorated according to a few simple rules. The
+ * arch description of the list or the field nodes may have one of the
+ * decoration-X attributes with a python expression as value. Then, for each
+ * record, we evaluate the python expression, and conditionnaly add the
+ * text-X css class to the element. This method is concerned with the
+ * computation of the list of css classes for a given record.
+ *
+ * @private
+ * @param {jQueryElement} $el the element to which to add the classes (a tr
+ * or td)
+ * @param {Object} decorations keys are the decoration classes (e.g.
+ * 'text-bf') and values are the python expressions to evaluate
+ * @param {Object} record a basic model record
+ */
+ _setDecorationClasses: function ($el, decorations, record) {
+ for (const [cssClass, expr] of Object.entries(decorations)) {
+ $el.toggleClass(cssClass, py.PY_isTrue(py.evaluate(expr, record.evalContext)));
+ }
+ },
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _shouldRenderPager: function (currentMinimum, limit, size) {
+ if (!limit || !size) {
+ return false;
+ }
+ const maximum = Math.min(currentMinimum + limit - 1, size);
+ const singlePage = (1 === currentMinimum) && (maximum === size);
+ return !singlePage;
+ },
+ /**
+ * Update the footer aggregate values. This method should be called each
+ * time the state of some field is changed, to make sure their sum are kept
+ * in sync.
+ *
+ * @private
+ */
+ _updateFooter: function () {
+ this._computeAggregates();
+ this.$('tfoot').replaceWith(this._renderFooter());
+ },
+ /**
+ * Whenever we change the state of the selected rows, we need to call this
+ * method to keep the this.selection variable in sync, and also to recompute
+ * the aggregates.
+ *
+ * @private
+ */
+ _updateSelection: function () {
+ const previousSelection = JSON.stringify(this.selection);
+ this.selection = [];
+ var self = this;
+ var $inputs = this.$('tbody .o_list_record_selector input:visible:not(:disabled)');
+ var allChecked = $inputs.length > 0;
+ $inputs.each(function (index, input) {
+ if (input.checked) {
+ self.selection.push($(input).closest('tr').data('id'));
+ } else {
+ allChecked = false;
+ }
+ });
+ this.$('thead .o_list_record_selector input').prop('checked', allChecked);
+ if (JSON.stringify(this.selection) !== previousSelection) {
+ this.trigger_up('selection_changed', { allChecked, selection: this.selection });
+ }
+ this._updateFooter();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} record a record dataPoint on which the button applies
+ * @param {Object} node arch node of the button
+ * @param {Object} node.attrs the attrs of the button in the arch
+ * @param {jQueryEvent} ev
+ */
+ _onGroupButtonClicked: function (record, node, ev) {
+ ev.stopPropagation();
+ if (node.attrs.type === 'edit') {
+ this.trigger_up('group_edit_button_clicked', {
+ record: record,
+ });
+ } else {
+ this.trigger_up('button_clicked', {
+ attrs: node.attrs,
+ record: record,
+ });
+ }
+ },
+ /**
+ * If the user presses ENTER on a group header button, we want to execute
+ * the button action. This is done automatically as the click handler is
+ * called. However, we have to stop the propagation of the event to prevent
+ * another handler from closing the group (see _onKeyDown).
+ *
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onGroupButtonKeydown: function (ev) {
+ if (ev.keyCode === $.ui.keyCode.ENTER) {
+ ev.stopPropagation();
+ }
+ },
+ /**
+ * When the user clicks on the checkbox in optional fields dropdown the
+ * column is added to listview and displayed
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onToggleOptionalColumn: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ // when the input's label is clicked, the click event is also raised on the
+ // input itself (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/label),
+ // so this handler is executed twice (except if the rendering is quick enough,
+ // as when we render, we empty the HTML)
+ ev.preventDefault();
+ var input = ev.currentTarget.querySelector('input');
+ var fieldIndex = this.optionalColumnsEnabled.indexOf(input.name);
+ if (fieldIndex >= 0) {
+ this.optionalColumnsEnabled.splice(fieldIndex, 1);
+ } else {
+ this.optionalColumnsEnabled.push(input.name);
+ }
+ this.trigger_up('save_optional_fields', {
+ keyParts: this._getOptionalColumnsStorageKeyParts(),
+ optionalColumnsEnabled: this.optionalColumnsEnabled,
+ });
+ this._processColumns(this.columnInvisibleFields);
+ this._render().then(function () {
+ self._onToggleOptionalColumnDropdown(ev);
+ });
+ },
+ /**
+ * When the user clicks on the three dots (ellipsis), toggle the optional
+ * fields dropdown.
+ *
+ * @private
+ */
+ _onToggleOptionalColumnDropdown: function (ev) {
+ // The dropdown toggle is inside the overflow hidden container because
+ // the ellipsis is always in the last column, but we want the actual
+ // dropdown to be outside of the overflow hidden container since it
+ // could easily have a higher height than the table. However, separating
+ // the toggle and the dropdown itself is not supported by popper.js by
+ // default, which is why we need to toggle the dropdown manually.
+ ev.stopPropagation();
+ this.$('.o_optional_columns .dropdown-toggle').dropdown('toggle');
+ // Explicitly set left/right of the optional column dropdown as it is pushed
+ // inside this.$el, so we need to position it at the end of top left corner.
+ var position = (this.$(".table-responsive").css('overflow') === "auto" ? this.$el.width() :
+ this.$('table').width());
+ var direction = "left";
+ if (_t.database.parameters.direction === 'rtl') {
+ position = position - this.$('.o_optional_columns .o_optional_columns_dropdown').width();
+ direction = "right";
+ }
+ this.$('.o_optional_columns').css(direction, position);
+ },
+ /**
+ * Manages the keyboard events on the list. If the list is not editable, when the user navigates to
+ * a cell using the keyboard, if he presses enter, enter the model represented by the line
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeyDown: function (ev) {
+ var $cell = $(ev.currentTarget);
+ var $tr;
+ var $futureCell;
+ var colIndex;
+ if (this.state.isSample) {
+ return; // we disable keyboard navigation inside the table in "sample" mode
+ }
+ switch (ev.keyCode) {
+ case $.ui.keyCode.LEFT:
+ ev.preventDefault();
+ $tr = $cell.closest('tr');
+ $tr.closest('tbody').addClass('o_keyboard_navigation');
+ if ($tr.hasClass('o_group_header') && $tr.hasClass('o_group_open')) {
+ this._onToggleGroup(ev);
+ } else {
+ $futureCell = $cell.prev();
+ }
+ break;
+ case $.ui.keyCode.RIGHT:
+ ev.preventDefault();
+ $tr = $cell.closest('tr');
+ $tr.closest('tbody').addClass('o_keyboard_navigation');
+ if ($tr.hasClass('o_group_header') && !$tr.hasClass('o_group_open')) {
+ this._onToggleGroup(ev);
+ } else {
+ $futureCell = $cell.next();
+ }
+ break;
+ case $.ui.keyCode.UP:
+ ev.preventDefault();
+ $cell.closest('tbody').addClass('o_keyboard_navigation');
+ colIndex = this.currentColIndex || $cell.index();
+ $futureCell = this._findConnectedCell($cell, 'prev', colIndex);
+ if (!$futureCell) {
+ this.trigger_up('navigation_move', { direction: 'up' });
+ }
+ break;
+ case $.ui.keyCode.DOWN:
+ ev.preventDefault();
+ $cell.closest('tbody').addClass('o_keyboard_navigation');
+ colIndex = this.currentColIndex || $cell.index();
+ $futureCell = this._findConnectedCell($cell, 'next', colIndex);
+ break;
+ case $.ui.keyCode.ENTER:
+ ev.preventDefault();
+ $tr = $cell.closest('tr');
+ if ($tr.hasClass('o_group_header')) {
+ this._onToggleGroup(ev);
+ } else {
+ var id = $tr.data('id');
+ if (id) {
+ this.trigger_up('open_record', { id: id, target: ev.target });
+ }
+ }
+ break;
+ }
+ if ($futureCell) {
+ // If the cell contains activable elements, focus them instead (except if it is in a
+ // group header, in which case we want to activate the whole header, so that we can
+ // open/close it with RIGHT/LEFT keystrokes)
+ var isInGroupHeader = $futureCell.closest('tr').hasClass('o_group_header');
+ var $activables = !isInGroupHeader && $futureCell.find(':focusable');
+ if ($activables.length) {
+ $activables[0].focus();
+ } else {
+ $futureCell.focus();
+ }
+ }
+ },
+ /**
+ * @private
+ */
+ _onMouseDown: function () {
+ $('.o_keyboard_navigation').removeClass('o_keyboard_navigation');
+ },
+ /**
+ * @private
+ * @param {OwlEvent} ev
+ * @param {Object} group
+ */
+ _onPagerChanged: async function (ev, group) {
+ ev.stopPropagation();
+ const { currentMinimum, limit } = ev.detail;
+ this.trigger_up('load', {
+ id: group.id,
+ limit: limit,
+ offset: currentMinimum - 1,
+ on_success: reloadedGroup => {
+ Object.assign(group, reloadedGroup);
+ this._render();
+ },
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onRowClicked: function (ev) {
+ // The special_click property explicitely allow events to bubble all
+ // the way up to bootstrap's level rather than being stopped earlier.
+ if (!ev.target.closest('.o_list_record_selector') && !$(ev.target).prop('special_click')) {
+ var id = $(ev.currentTarget).data('id');
+ if (id) {
+ this.trigger_up('open_record', { id: id, target: ev.target });
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onSelectRecord: function (ev) {
+ ev.stopPropagation();
+ this._updateSelection();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onSortColumn: function (ev) {
+ var name = $(ev.currentTarget).data('name');
+ this.trigger_up('toggle_column_order', { id: this.state.id, name: name });
+ },
+ /**
+ * When the user clicks on the whole record selector cell, we want to toggle
+ * the checkbox, to make record selection smooth.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onToggleCheckbox: function (ev) {
+ const $recordSelector = $(ev.target).find('input[type=checkbox]:not(":disabled")');
+ $recordSelector.prop('checked', !$recordSelector.prop("checked"));
+ $recordSelector.change(); // s.t. th and td checkbox cases are handled by their own handler
+ },
+ /**
+ * @private
+ * @param {DOMEvent} ev
+ */
+ _onToggleGroup: function (ev) {
+ ev.preventDefault();
+ var group = $(ev.currentTarget).closest('tr').data('group');
+ if (group.count) {
+ this.trigger_up('toggle_group', {
+ group: group,
+ onSuccess: () => {
+ this._updateSelection();
+ // Refocus the header after re-render unless the user
+ // already focused something else by now
+ if (document.activeElement.tagName === 'BODY') {
+ var groupHeaders = $('tr.o_group_header:data("group")');
+ var header = groupHeaders.filter(function () {
+ return $(this).data('group').id === group.id;
+ });
+ header.find('.o_group_name').focus();
+ }
+ },
+ });
+ }
+ },
+ /**
+ * When the user clicks on the row selection checkbox in the header, we
+ * need to update the checkbox of the row selection checkboxes in the body.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onToggleSelection: function (ev) {
+ var checked = $(ev.currentTarget).prop('checked') || false;
+ this.$('tbody .o_list_record_selector input:not(":disabled")').prop('checked', checked);
+ this._updateSelection();
+ },
+});
+
+return ListRenderer;
+});
diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js
new file mode 100644
index 00000000..5c36d01e
--- /dev/null
+++ b/addons/web/static/src/js/views/list/list_view.js
@@ -0,0 +1,137 @@
+odoo.define('web.ListView', function (require) {
+"use strict";
+
+/**
+ * The list view is one of the core and most basic view: it is used to look at
+ * a list of records in a table.
+ *
+ * Note that a list view is not instantiated to display a one2many field in a
+ * form view. Only a ListRenderer is used in that case.
+ */
+
+var BasicView = require('web.BasicView');
+var core = require('web.core');
+var ListModel = require('web.ListModel');
+var ListRenderer = require('web.ListRenderer');
+var ListController = require('web.ListController');
+var pyUtils = require('web.py_utils');
+
+var _lt = core._lt;
+
+var ListView = BasicView.extend({
+ accesskey: "l",
+ display_name: _lt('List'),
+ icon: 'fa-list-ul',
+ config: _.extend({}, BasicView.prototype.config, {
+ Model: ListModel,
+ Renderer: ListRenderer,
+ Controller: ListController,
+ }),
+ viewType: 'list',
+ /**
+ * @override
+ *
+ * @param {Object} viewInfo
+ * @param {Object} params
+ * @param {boolean} params.hasActionMenus
+ * @param {boolean} [params.hasSelectors=true]
+ */
+ init: function (viewInfo, params) {
+ var self = this;
+ this._super.apply(this, arguments);
+ var selectedRecords = []; // there is no selected records by default
+
+ var pyevalContext = py.dict.fromJSON(_.pick(params.context, function(value, key, object) {return !_.isUndefined(value)}) || {});
+ var expandGroups = !!JSON.parse(pyUtils.py_eval(this.arch.attrs.expand || "0", {'context': pyevalContext}));
+
+ this.groupbys = {};
+ this.headerButtons = [];
+ this.arch.children.forEach(function (child) {
+ if (child.tag === 'groupby') {
+ self._extractGroup(child);
+ }
+ if (child.tag === 'header') {
+ self._extractHeaderButtons(child);
+ }
+ });
+
+ let editable = false;
+ if ((!this.arch.attrs.edit || !!JSON.parse(this.arch.attrs.edit)) && !params.readonly) {
+ editable = this.arch.attrs.editable;
+ }
+
+ this.controllerParams.activeActions.export_xlsx = this.arch.attrs.export_xlsx ? !!JSON.parse(this.arch.attrs.export_xlsx): true;
+ this.controllerParams.editable = editable;
+ this.controllerParams.hasActionMenus = params.hasActionMenus;
+ this.controllerParams.headerButtons = this.headerButtons;
+ this.controllerParams.toolbarActions = viewInfo.toolbar;
+ this.controllerParams.mode = 'readonly';
+ this.controllerParams.selectedRecords = selectedRecords;
+
+ this.rendererParams.arch = this.arch;
+ this.rendererParams.groupbys = this.groupbys;
+ this.rendererParams.hasSelectors =
+ 'hasSelectors' in params ? params.hasSelectors : true;
+ this.rendererParams.editable = editable;
+ this.rendererParams.selectedRecords = selectedRecords;
+ this.rendererParams.addCreateLine = false;
+ this.rendererParams.addCreateLineInGroups = editable && this.controllerParams.activeActions.create;
+ this.rendererParams.isMultiEditable = this.arch.attrs.multi_edit && !!JSON.parse(this.arch.attrs.multi_edit);
+
+ this.modelParams.groupbys = this.groupbys;
+
+ this.loadParams.limit = this.loadParams.limit || 80;
+ this.loadParams.openGroupByDefault = expandGroups;
+ this.loadParams.type = 'list';
+ var groupsLimit = parseInt(this.arch.attrs.groups_limit, 10);
+ this.loadParams.groupsLimit = groupsLimit || (expandGroups ? 10 : 80);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} node
+ */
+ _extractGroup: function (node) {
+ var innerView = this.fields[node.attrs.name].views.groupby;
+ this.groupbys[node.attrs.name] = this._processFieldsView(innerView, 'groupby');
+ },
+ /**
+ * Extracts action buttons definitions from the <header> node of the list
+ * view definition
+ *
+ * @private
+ * @param {Object} node
+ */
+ _extractHeaderButtons(node) {
+ node.children.forEach(child => {
+ if (child.tag === 'button' && !child.attrs.modifiers.invisible) {
+ this.headerButtons.push(child);
+ }
+ });
+ },
+ /**
+ * @override
+ */
+ _extractParamsFromAction: function (action) {
+ var params = this._super.apply(this, arguments);
+ var inDialog = action.target === 'new';
+ var inline = action.target === 'inline';
+ params.hasActionMenus = !inDialog && !inline;
+ return params;
+ },
+ /**
+ * @override
+ */
+ _updateMVCParams: function () {
+ this._super.apply(this, arguments);
+ this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf;
+ },
+});
+
+return ListView;
+
+});
diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js
new file mode 100644
index 00000000..11387781
--- /dev/null
+++ b/addons/web/static/src/js/views/pivot/pivot_controller.js
@@ -0,0 +1,325 @@
+odoo.define('web.PivotController', function (require) {
+ "use strict";
+ /**
+ * Odoo Pivot Table Controller
+ *
+ * This class is the Controller for the pivot table view. It has to coordinate
+ * the actions coming from the search view (through the update method), from
+ * the renderer, from the model, and from the control panel.
+ *
+ * It can display action buttons in the control panel, to select a different
+ * measure, or to perform some other actions such as download/expand/flip the
+ * view.
+ */
+
+ const AbstractController = require('web.AbstractController');
+ const core = require('web.core');
+ const framework = require('web.framework');
+ const session = require('web.session');
+
+ const _t = core._t;
+ const QWeb = core.qweb;
+
+ const PivotController = AbstractController.extend({
+ custom_events: Object.assign({}, AbstractController.prototype.custom_events, {
+ closed_header_click: '_onClosedHeaderClicked',
+ open_view: '_onOpenView',
+ opened_header_click: '_onOpenedHeaderClicked',
+ sort_rows: '_onSortRows',
+ groupby_menu_selection: '_onGroupByMenuSelection',
+ }),
+
+ /**
+ * @override
+ * @param parent
+ * @param model
+ * @param renderer
+ * @param {Object} params
+ * @param {Object} params.groupableFields a map from field names to field
+ * props
+ */
+ init: function (parent, model, renderer, params) {
+ this._super(...arguments);
+
+ this.disableLinking = params.disableLinking;
+ this.measures = params.measures;
+ this.title = params.title;
+ // views to use in the action triggered when a data cell is clicked
+ this.views = params.views;
+ this.groupSelected = null;
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this.$buttons) {
+ // remove jquery's tooltip() handlers
+ this.$buttons.find('button').off();
+ }
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns the current measures and groupbys, so we can restore the view
+ * when we save the current state in the search view, or when we add it to
+ * the dashboard.
+ *
+ * @override method from AbstractController
+ * @returns {Object}
+ */
+ getOwnedQueryParams: function () {
+ const state = this.model.get({ raw: true });
+ return {
+ context: {
+ pivot_measures: state.measures,
+ pivot_column_groupby: state.colGroupBys,
+ pivot_row_groupby: state.rowGroupBys,
+ }
+ };
+ },
+ /**
+ * Render the buttons according to the PivotView.buttons template and
+ * add listeners on it.
+ * Set this.$buttons with the produced jQuery element
+ *
+ * @override
+ * @param {jQuery} [$node] a jQuery node where the rendered buttons should
+ * be inserted. $node may be undefined, in which case the PivotView
+ * does nothing
+ */
+ renderButtons: function ($node) {
+ const context = this._getRenderButtonContext();
+ this.$buttons = $(QWeb.render('PivotView.buttons', context));
+ this.$buttons.click(this._onButtonClick.bind(this));
+ this.$buttons.find('button').tooltip();
+ if ($node) {
+ this.$buttons.appendTo($node);
+ }
+ },
+ /**
+ * @override
+ */
+ updateButtons: function () {
+ if (!this.$buttons) {
+ return;
+ }
+ const state = this.model.get({ raw: true });
+ Object.entries(this.measures).forEach(elt => {
+ const name = elt[0];
+ const isSelected = state.measures.includes(name);
+ this.$buttons.find('.dropdown-item[data-field="' + name + '"]')
+ .toggleClass('selected', isSelected);
+
+ });
+ const noDataDisplayed = !state.hasData || !state.measures.length;
+ this.$buttons.find('.o_pivot_flip_button').prop('disabled', noDataDisplayed);
+ this.$buttons.find('.o_pivot_expand_button').prop('disabled', noDataDisplayed);
+ this.$buttons.find('.o_pivot_download').prop('disabled', noDataDisplayed);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Export the current pivot table data in a xls file. For this, we have to
+ * serialize the current state, then call the server /web/pivot/export_xlsx.
+ * Force a reload before exporting to ensure to export up-to-date data.
+ *
+ * @private
+ */
+ _downloadTable: function () {
+ if (this.model.getTableWidth() > 16384) {
+ this.call('crash_manager', 'show_message', _t("For Excel compatibility, data cannot be exported if there are more than 16384 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures."));
+ framework.unblockUI();
+ return;
+ }
+ const table = this.model.exportData();
+ table.title = this.title;
+ session.get_file({
+ url: '/web/pivot/export_xlsx',
+ data: { data: JSON.stringify(table) },
+ complete: framework.unblockUI,
+ error: (error) => this.call('crash_manager', 'rpc_error', error),
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * This handler is called when the user clicked on a button in the control
+ * panel. We then have to react properly: it can either be a change in the
+ * current measures, or a request to flip/expand/download data.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onButtonClick: async function (ev) {
+ const $target = $(ev.target);
+ if ($target.hasClass('o_pivot_flip_button')) {
+ this.model.flip();
+ this.update({}, { reload: false });
+ }
+ if ($target.hasClass('o_pivot_expand_button')) {
+ await this.model.expandAll();
+ this.update({}, { reload: false });
+ }
+ if (ev.target.closest('.o_pivot_measures_list')) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const field = ev.target.dataset.field;
+ if (field) {
+ this.update({ measure: field });
+ }
+ }
+ if ($target.hasClass('o_pivot_download')) {
+ this._downloadTable();
+ }
+
+ await this._addIncludedButtons(ev);
+ },
+
+ /**
+ * Declared to be overwritten in includes of pivot controller
+ *
+ * @param {MouseEvent} ev
+ * @returns {Promise<void>}
+ * @private
+ */
+ _addIncludedButtons: async function(ev) {},
+ /**
+ * Get the context of rendering of the buttons
+ *
+ * @returns {Object}
+ * @private
+ */
+ _getRenderButtonContext: function () {
+ return {
+ measures: Object.entries(this.measures)
+ .filter(x => x[0] !== '__count')
+ .sort((a, b) => a[1].string.toLowerCase() > b[1].string.toLowerCase() ? 1 : -1),
+ };
+ },
+ /**
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onCloseGroup: function (ev) {
+ this.model.closeGroup(ev.data.groupId, ev.data.type);
+ this.update({}, { reload: false });
+ },
+ /**
+ * @param {CustomEvent} ev
+ * @private
+ * */
+ _onOpenedHeaderClicked: function (ev) {
+ this.model.closeGroup(ev.data.cell.groupId, ev.data.type);
+ this.update({}, { reload: false });
+ },
+ /**
+ * @param {CustomEvent} ev
+ * @private
+ * */
+ _onClosedHeaderClicked: async function (ev) {
+ const cell = ev.data.cell;
+ const groupId = cell.groupId;
+ const type = ev.data.type;
+
+ const group = {
+ rowValues: groupId[0],
+ colValues: groupId[1],
+ type: type
+ };
+
+ const state = this.model.get({ raw: true });
+ const groupValues = type === 'row' ? groupId[0] : groupId[1];
+ const groupBys = type === 'row' ?
+ state.rowGroupBys :
+ state.colGroupBys;
+ this.selectedGroup = group;
+ if (groupValues.length < groupBys.length) {
+ const groupBy = groupBys[groupValues.length];
+ await this.model.expandGroup(this.selectedGroup, groupBy);
+ this.update({}, { reload: false });
+ }
+ },
+ /**
+ * This handler is called when the user selects a groupby in the dropdown menu.
+ *
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onGroupByMenuSelection: async function (ev) {
+ ev.stopPropagation();
+
+ let groupBy = ev.data.field.name;
+ const interval = ev.data.interval;
+ if (interval) {
+ groupBy = groupBy + ':' + interval;
+ }
+ this.model.addGroupBy(groupBy, this.selectedGroup.type);
+ await this.model.expandGroup(this.selectedGroup, groupBy);
+ this.update({}, { reload: false });
+ },
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onOpenView: function (ev) {
+ ev.stopPropagation();
+ const cell = ev.data;
+ if (cell.value === undefined || this.disableLinking) {
+ return;
+ }
+
+ const context = Object.assign({}, this.model.data.context);
+ Object.keys(context).forEach(x => {
+ if (x === 'group_by' || x.startsWith('search_default_')) {
+ delete context[x];
+ }
+ });
+
+ const group = {
+ rowValues: cell.groupId[0],
+ colValues: cell.groupId[1],
+ originIndex: cell.originIndexes[0]
+ };
+
+ const domain = this.model._getGroupDomain(group);
+
+ this.do_action({
+ type: 'ir.actions.act_window',
+ name: this.title,
+ res_model: this.modelName,
+ views: this.views,
+ view_mode: 'list',
+ target: 'current',
+ context: context,
+ domain: domain,
+ });
+ },
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onSortRows: function (ev) {
+ this.model.sortRows({
+ groupId: ev.data.groupId,
+ measure: ev.data.measure,
+ order: (ev.data.order || 'desc') === 'asc' ? 'desc' : 'asc',
+ originIndexes: ev.data.originIndexes,
+ });
+ this.update({}, { reload: false });
+ },
+ });
+
+ return PivotController;
+
+});
diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js
new file mode 100644
index 00000000..b4af34e0
--- /dev/null
+++ b/addons/web/static/src/js/views/pivot/pivot_model.js
@@ -0,0 +1,1569 @@
+odoo.define('web.PivotModel', function (require) {
+"use strict";
+
+/**
+ * Pivot Model
+ *
+ * The pivot model keeps an in-memory representation of the pivot table that is
+ * displayed on the screen. The exact layout of this representation is not so
+ * simple, because a pivot table is at its core a 2-dimensional object, but
+ * with a 'tree' component: some rows/cols can be expanded so we zoom into the
+ * structure.
+ *
+ * However, we need to be able to manipulate the data in a somewhat efficient
+ * way, and to transform it into a list of lines to be displayed by the renderer.
+ *
+ * Basicaly the pivot table presents aggregated values for various groups of records
+ * in one domain. If a comparison is asked for, two domains are considered.
+ *
+ * Let us consider a simple example and let us fix the vocabulary (let us suppose we are in June 2020):
+ * ___________________________________________________________________________________________________________________________________________
+ * | | Total |
+ * | |_____________________________________________________________________________________________________________________|
+ * | | Sale Team 1 | Sale Team 2 | |
+ * | |_______________________________________|______________________________________|______________________________________|
+ * | | Sales total | Sales total | Sales total |
+ * | |_______________________________________|______________________________________|______________________________________|
+ * | | May 2020 | June 2020 | Variation | May 2020 | June 2020 | Variation | May 2020 | June 2020 | Variation |
+ * |____________________|______________|____________|___________|_____________|____________|___________|_____________|____________|___________|
+ * | Total | 85 | 110 | 29.4% | 40 | 30 | -25% | 125 | 140 | 12% |
+ * | Europe | 25 | 35 | 40% | 40 | 30 | -25% | 65 | 65 | 0% |
+ * | Brussels | 0 | 15 | 100% | 30 | 30 | 0% | 30 | 45 | 50% |
+ * | Paris | 25 | 20 | -20% | 10 | 0 | -100% | 35 | 20 | -42.8% |
+ * | North America | 60 | 75 | 25% | | | | 60 | 75 | 25% |
+ * | Washington | 60 | 75 | 25% | | | | 60 | 75 | 25% |
+ * |____________________|______________|____________|___________|_____________|____________|___________|_____________|____________|___________|
+ *
+ *
+ * META DATA:
+ *
+ * In the above pivot table, the records have been grouped using the fields
+ *
+ * continent_id, city_id
+ *
+ * for rows and
+ *
+ * sale_team_id
+ *
+ * for columns.
+ *
+ * The measure is the field 'sales_total'.
+ *
+ * Two domains are considered: 'May 2020' and 'June 2020'.
+ *
+ * In the model,
+ *
+ * - rowGroupBys is the list [continent_id, city_id]
+ * - colGroupBys is the list [sale_team_id]
+ * - measures is the list [sales_total]
+ * - domains is the list [d1, d2] with d1 and d2 domain expressions
+ * for say sale_date in May 2020 and June 2020, for instance
+ * d1 = [['sale_date', >=, 2020-05-01], ['sale_date', '<=', 2020-05-31]]
+ * - origins is the list ['May 2020', 'June 2020']
+ *
+ * DATA:
+ *
+ * Recall that a group is constituted by records (in a given domain)
+ * that have the same (raw) values for a list of fields.
+ * Thus the group itself is identified by this list and the domain.
+ * In comparison mode, the same group (forgetting the domain part or 'originIndex')
+ * can be eventually found in the two domains.
+ * This defines the way in which the groups are identified or not.
+ *
+ * In the above table, (forgetting the domain) the following groups are found:
+ *
+ * the 'row groups'
+ * - Total
+ * - Europe
+ * - America
+ * - Europe, Brussels
+ * - Europe, Paris
+ * - America, Washington
+ *
+ * the 'col groups'
+ *
+ * - Total
+ * - Sale Team 1
+ * - Sale Team 2
+ *
+ * and all non trivial combinations of row groups and col groups
+ *
+ * - Europe, Sale Team 1
+ * - Europe, Brussels, Sale Team 2
+ * - America, Washington, Sale Team 1
+ * - ...
+ *
+ * The list of fields is created from the concatenation of two lists of fields, the first in
+ *
+ * [], [f1], [f1, f2], ... [f1, f2, ..., fn] for [f1, f2, ..., fn] the full list of groupbys
+ * (called rowGroupBys) used to create row groups
+ *
+ * In the example: [], [continent_id], [continent_id, city_id].
+ *
+ * and the second in
+ * [], [g1], [g1, g2], ... [g1, g2, ..., gm] for [g1, g2, ..., gm] the full list of groupbys
+ * (called colGroupBys) used to create col groups.
+ *
+ * In the example: [], [sale_team_id].
+ *
+ * Thus there are (n+1)*(m+1) lists of fields possible.
+ *
+ * In the example: 6 lists possible, namely [],
+ * [continent_id], [sale_team_id],
+ * [continent_id, sale_team_id], [continent_id, city_id],
+ * [continent_id, city_id, sale_team_id]
+ *
+ * A given list is thus of the form [f1,..., fi, g1,..., gj] or better [[f1,...,fi], [g1,...,gj]]
+ *
+ * For each list of fields possible and each domain considered, one read_group is done
+ * and gives results of the form (an exception for list [])
+ *
+ * g = {
+ * f1: v1, ..., fi: vi,
+ * g1: w1, ..., gj: wj,
+ * m1: x1, ..., mk: xk,
+ * __count: c,
+ * __domain: d
+ * }
+ *
+ * where v1,...,vi,w1,...,Wj are 'values' for the corresponding fields and
+ * m1,...,mk are the fields selected as measures.
+ *
+ * For example, g = {
+ * continent_id: [1, 'Europe']
+ * sale_team_id: [1, 'Sale Team 1']
+ * sales_count: 25,
+ * __count: 4
+ * __domain: [
+ * ['sale_date', >=, 2020-05-01], ['sale_date', '<=', 2020-05-31],
+ * ['continent_id', '=', 1],
+ * ['sale_team_id', '=', 1]
+ * ]
+ * }
+ *
+ * Thus the above group g is fully determined by [[v1,...,vi], [w1,...,wj]] and the base domain
+ * or the corresponding 'originIndex'.
+ *
+ * When j=0, g corresponds to a row group (or also row header) and is of the form [[v1,...,vi], []] or more simply [v1,...vi]
+ * (not forgetting the list [v1,...vi] comes from left).
+ * When i=0, g corresponds to a col group (or col header) and is of the form [[], [w1,...,wj]] or more simply [w1,...,wj].
+ *
+ * A generic group g as above [[v1,...,vi], [w1,...,wj]] corresponds to the two headers [[v1,...,vi], []]
+ * and [[], [w1,...,wj]].
+ *
+ * Here is a description of the data structure manipulated by the pivot model.
+ *
+ * Five objects contain all the data from the read_groups
+ *
+ * - rowGroupTree: contains information on row headers
+ * the nodes correspond to the groups of the form [[v1,...,vi], []]
+ * The root is [[], []].
+ * A node [[v1,...,vl], []] has as direct children the nodes of the form [[v1,...,vl,v], []],
+ * this means that a direct child is obtained by grouping records using the single field fi+1
+ *
+ * The structure at each level is of the form
+ *
+ * {
+ * root: {
+ * values: [v1,...,vl],
+ * labels: [la1,...,lal]
+ * },
+ * directSubTrees: {
+ * v => {
+ * root: {
+ * values: [v1,...,vl,v]
+ * labels: [label1,...,labell,label]
+ * },
+ * directSubTrees: {...}
+ * },
+ * v' => {...},
+ * ...
+ * }
+ * }
+ *
+ * (directSubTrees is a Map instance)
+ *
+ * In the example, the rowGroupTree is:
+ *
+ * {
+ * root: {
+ * values: [],
+ * labels: []
+ * },
+ * directSubTrees: {
+ * 1 => {
+ * root: {
+ * values: [1],
+ * labels: ['Europe'],
+ * },
+ * directSubTrees: {
+ * 1 => {
+ * root: {
+ * values: [1, 1],
+ * labels: ['Europe', 'Brussels'],
+ * },
+ * directSubTrees: new Map(),
+ * },
+ * 2 => {
+ * root: {
+ * values: [1, 2],
+ * labels: ['Europe', 'Paris'],
+ * },
+ * directSubTrees: new Map(),
+ * },
+ * },
+ * },
+ * 2 => {
+ * root: {
+ * values: [2],
+ * labels: ['America'],
+ * },
+ * directSubTrees: {
+ * 3 => {
+ * root: {
+ * values: [2, 3],
+ * labels: ['America', 'Washington'],
+ * }
+ * directSubTrees: new Map(),
+ * },
+ * },
+ * },
+ * },
+ * }
+ *
+ * - colGroupTree: contains information on col headers
+ * The same as above with right instead of left
+ *
+ * - measurements: contains information on measure values for all the groups
+ *
+ * the object keys are of the form JSON.stringify([[v1,...,vi], [w1,...,wj]])
+ * and values are arrays of length equal to number of origins containing objects of the form
+ * {m1: x1,...,mk: xk}
+ * The structure looks like
+ *
+ * {
+ * JSON.stringify([[], []]): [{m1: x1,...,mk: xk}, {m1: x1',...,mk: xk'},...]
+ * ....
+ * JSON.stringify([[v1,...,vi], [w1,...,wj]]): [{m1: y1',...,mk: yk'}, {m1: y1',...,mk: yk'},...],
+ * ....
+ * JSON.stringify([[v1,...,vn], [w1,...,wm]]): [{m1: z1',...,mk: zk'}, {m1: z1',...,mk: zk'},...],
+ * }
+ * Thus the structure contains all information for all groups and all origins on measure values.
+ *
+ *
+ * this.measurments["[[], []]"][0]['foo'] gives the value of the measure 'foo' for the group 'Total' and the
+ * first domain (origin).
+ *
+ * In the example:
+ * {
+ * "[[], []]": [{'sales_total': 125}, {'sales_total': 140}] (total/total)
+ * ...
+ * "[[1, 2], [2]]": [{'sales_total': 10}, {'sales_total': 0}] (Europe/Paris/Sale Team 2)
+ * ...
+ * }
+ *
+ * - counts: contains information on the number of records in each groups
+ * The structure is similar to the above but the arrays contains numbers (counts)
+ * - groupDomains:
+ * The structure is similar to the above but the arrays contains domains
+ *
+ * With this light data structures, all manipulation done by the model are eased and redundancies are limited.
+ * Each time a rendering or an export of the data has to be done, the pivot table is generated by the _getTable function.
+ */
+
+var AbstractModel = require('web.AbstractModel');
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var dataComparisonUtils = require('web.dataComparisonUtils');
+const Domain = require('web.Domain');
+var mathUtils = require('web.mathUtils');
+var session = require('web.session');
+
+
+var _t = core._t;
+var cartesian = mathUtils.cartesian;
+var computeVariation = dataComparisonUtils.computeVariation;
+var sections = mathUtils.sections;
+
+var PivotModel = AbstractModel.extend({
+ /**
+ * @override
+ * @param {Object} params
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.numbering = {};
+ this.data = null;
+ this._loadDataDropPrevious = new concurrency.DropPrevious();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a groupBy to rowGroupBys or colGroupBys according to provided type.
+ *
+ * @param {string} groupBy
+ * @param {'row'|'col'} type
+ */
+ addGroupBy: function (groupBy, type) {
+ if (type === 'row') {
+ this.data.expandedRowGroupBys.push(groupBy);
+ } else {
+ this.data.expandedColGroupBys.push(groupBy);
+ }
+ },
+ /**
+ * Close the group with id given by groupId. A type must be specified
+ * in case groupId is [[], []] (the id of the group 'Total') because this
+ * group is present in both colGroupTree and rowGroupTree.
+ *
+ * @param {Array[]} groupId
+ * @param {'row'|'col'} type
+ */
+ closeGroup: function (groupId, type) {
+ var groupBys;
+ var expandedGroupBys;
+ let keyPart;
+ var group;
+ var tree;
+ if (type === 'row') {
+ groupBys = this.data.rowGroupBys;
+ expandedGroupBys = this.data.expandedRowGroupBys;
+ tree = this.rowGroupTree;
+ group = this._findGroup(this.rowGroupTree, groupId[0]);
+ keyPart = 0;
+ } else {
+ groupBys = this.data.colGroupBys;
+ expandedGroupBys = this.data.expandedColGroupBys;
+ tree = this.colGroupTree;
+ group = this._findGroup(this.colGroupTree, groupId[1]);
+ keyPart = 1;
+ }
+
+ const groupIdPart = groupId[keyPart];
+ const range = groupIdPart.map((_, index) => index);
+ function keep(key) {
+ const idPart = JSON.parse(key)[keyPart];
+ return range.some(index => groupIdPart[index] !== idPart[index]) ||
+ idPart.length === groupIdPart.length;
+ }
+ function omitKeys(object) {
+ const newObject = {};
+ for (const key in object) {
+ if (keep(key)) {
+ newObject[key] = object[key];
+ }
+ }
+ return newObject;
+ }
+ this.measurements = omitKeys(this.measurements);
+ this.counts = omitKeys(this.counts);
+ this.groupDomains = omitKeys(this.groupDomains);
+
+ group.directSubTrees.clear();
+ delete group.sortedKeys;
+ var newGroupBysLength = this._getTreeHeight(tree) - 1;
+ if (newGroupBysLength <= groupBys.length) {
+ expandedGroupBys.splice(0);
+ groupBys.splice(newGroupBysLength);
+ } else {
+ expandedGroupBys.splice(newGroupBysLength - groupBys.length);
+ }
+ },
+ /**
+ * Reload the view with the current rowGroupBys and colGroupBys
+ * This is the easiest way to expand all the groups that are not expanded
+ *
+ * @returns {Promise}
+ */
+ expandAll: function () {
+ return this._loadData();
+ },
+ /**
+ * Expand a group by using groupBy to split it.
+ *
+ * @param {Object} group
+ * @param {string} groupBy
+ * @returns {Promise}
+ */
+ expandGroup: async function (group, groupBy) {
+ var leftDivisors;
+ var rightDivisors;
+
+ if (group.type === 'row') {
+ leftDivisors = [[groupBy]];
+ rightDivisors = sections(this._getGroupBys().colGroupBys);
+ } else {
+ leftDivisors = sections(this._getGroupBys().rowGroupBys);
+ rightDivisors = [[groupBy]];
+ }
+ var divisors = cartesian(leftDivisors, rightDivisors);
+
+ delete group.type;
+ return this._subdivideGroup(group, divisors);
+ },
+ /**
+ * Export model data in a form suitable for an easy encoding of the pivot
+ * table in excell.
+ *
+ * @returns {Object}
+ */
+ exportData: function () {
+ var measureCount = this.data.measures.length;
+ var originCount = this.data.origins.length;
+
+ var table = this._getTable();
+
+ // process headers
+ var headers = table.headers;
+ var colGroupHeaderRows;
+ var measureRow = [];
+ var originRow = [];
+
+ function processHeader(header) {
+ var inTotalColumn = header.groupId[1].length === 0;
+ return {
+ title: header.title,
+ width: header.width,
+ height: header.height,
+ is_bold: !!header.measure && inTotalColumn
+ };
+ }
+
+ if (originCount > 1) {
+ colGroupHeaderRows = headers.slice(0, headers.length - 2);
+ measureRow = headers[headers.length - 2].map(processHeader);
+ originRow = headers[headers.length - 1].map(processHeader);
+ } else {
+ colGroupHeaderRows = headers.slice(0, headers.length - 1);
+ measureRow = headers[headers.length - 1].map(processHeader);
+ }
+
+ // remove the empty headers on left side
+ colGroupHeaderRows[0].splice(0, 1);
+
+ colGroupHeaderRows = colGroupHeaderRows.map(function (headerRow) {
+ return headerRow.map(processHeader);
+ });
+
+ // process rows
+ var tableRows = table.rows.map(function (row) {
+ return {
+ title: row.title,
+ indent: row.indent,
+ values: row.subGroupMeasurements.map(function (measurement) {
+ var value = measurement.value;
+ if (value === undefined) {
+ value = "";
+ } else if (measurement.originIndexes.length > 1) {
+ // in that case the value is a variation and a
+ // number between 0 and 1
+ value = value * 100;
+ }
+ return {
+ is_bold: measurement.isBold,
+ value: value,
+ };
+ }),
+ };
+ });
+
+ return {
+ col_group_headers: colGroupHeaderRows,
+ measure_headers: measureRow,
+ origin_headers: originRow,
+ rows: tableRows,
+ measure_count: measureCount,
+ origin_count: originCount,
+ };
+ },
+ /**
+ * Swap the pivot columns and the rows. It is a synchronous operation.
+ */
+ flip: function () {
+ // swap the data: the main column and the main row
+ var temp = this.rowGroupTree;
+ this.rowGroupTree = this.colGroupTree;
+ this.colGroupTree = temp;
+
+ // we need to update the record metadata: (expanded) row and col groupBys
+ temp = this.data.rowGroupBys;
+ this.data.groupedBy = this.data.colGroupBys;
+ this.data.rowGroupBys = this.data.colGroupBys;
+ this.data.colGroupBys = temp;
+ temp = this.data.expandedColGroupBys;
+ this.data.expandedColGroupBys = this.data.expandedRowGroupBys;
+ this.data.expandedRowGroupBys = temp;
+
+ function twistKey(key) {
+ return JSON.stringify(JSON.parse(key).reverse());
+ }
+
+ function twist(object) {
+ var newObject = {};
+ Object.keys(object).forEach(function (key) {
+ var value = object[key];
+ newObject[twistKey(key)] = value;
+ });
+ return newObject;
+ }
+
+ this.measurements = twist(this.measurements);
+ this.counts = twist(this.counts);
+ this.groupDomains = twist(this.groupDomains);
+ },
+ /**
+ * @override
+ *
+ * @param {Object} [options]
+ * @param {boolean} [options.raw=false]
+ * @returns {Object}
+ */
+ __get: function (options) {
+ options = options || {};
+ var raw = options.raw || false;
+ var groupBys = this._getGroupBys();
+ var state = {
+ colGroupBys: groupBys.colGroupBys,
+ context: this.data.context,
+ domain: this.data.domain,
+ fields: this.fields,
+ hasData: this._hasData(),
+ isSample: this.isSampleModel,
+ measures: this.data.measures,
+ origins: this.data.origins,
+ rowGroupBys: groupBys.rowGroupBys,
+ selectionGroupBys: this._getSelectionGroupBy(groupBys),
+ modelName: this.modelName
+ };
+ if (!raw && state.hasData) {
+ state.table = this._getTable();
+ state.tree = this.rowGroupTree;
+ }
+ return state;
+ },
+ /**
+ * Returns the total number of columns of the pivot table.
+ *
+ * @returns {integer}
+ */
+ getTableWidth: function () {
+ var leafCounts = this._getLeafCounts(this.colGroupTree);
+ return leafCounts[JSON.stringify(this.colGroupTree.root.values)] + 2;
+ },
+ /**
+ * @override
+ *
+ * @param {Object} params
+ * @param {boolean} [params.compare=false]
+ * @param {Object} params.context
+ * @param {Object} params.fields
+ * @param {string[]} [params.groupedBy]
+ * @param {string[]} params.colGroupBys
+ * @param {Array[]} params.domain
+ * @param {string[]} params.measures
+ * @param {string[]} params.rowGroupBys
+ * @param {string} [params.default_order]
+ * @param {string} params.modelName
+ * @param {Object[]} params.groupableFields
+ * @param {Object} params.timeRanges
+ * @returns {Promise}
+ */
+ __load: function (params) {
+ this.initialDomain = params.domain;
+ this.initialRowGroupBys = params.context.pivot_row_groupby || params.rowGroupBys;
+ this.defaultGroupedBy = params.groupedBy;
+
+ this.fields = params.fields;
+ this.modelName = params.modelName;
+ this.groupableFields = params.groupableFields;
+ const measures = this._processMeasures(params.context.pivot_measures) ||
+ params.measures.map(m => m);
+ this.data = {
+ expandedRowGroupBys: [],
+ expandedColGroupBys: [],
+ domain: this.initialDomain,
+ context: _.extend({}, session.user_context, params.context),
+ groupedBy: params.context.pivot_row_groupby || params.groupedBy,
+ colGroupBys: params.context.pivot_column_groupby || params.colGroupBys,
+ measures,
+ timeRanges: params.timeRanges,
+ };
+ this._computeDerivedParams();
+
+ this.data.groupedBy = this.data.groupedBy.slice();
+ this.data.rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys.slice();
+
+ var defaultOrder = params.default_order && params.default_order.split(' ');
+ if (defaultOrder) {
+ this.data.sortedColumn = {
+ groupId: [[], []],
+ measure: defaultOrder[0],
+ order: defaultOrder[1] ? defaultOrder [1] : 'asc',
+ };
+ }
+ return this._loadData();
+ },
+ /**
+ * @override
+ *
+ * @param {any} handle this parameter is ignored
+ * @param {Object} params
+ * @param {boolean} [params.compare=false]
+ * @param {Object} params.context
+ * @param {string[]} [params.groupedBy]
+ * @param {Array[]} params.domain
+ * @param {string[]} params.groupBy
+ * @param {string[]} params.measures
+ * @param {Object} [params.timeRanges]
+ * @returns {Promise}
+ */
+ __reload: function (handle, params) {
+ var self = this;
+ var oldColGroupBys = this.data.colGroupBys;
+ var oldRowGroupBys = this.data.rowGroupBys;
+ if ('context' in params) {
+ this.data.context = params.context;
+ this.data.colGroupBys = params.context.pivot_column_groupby || this.data.colGroupBys;
+ this.data.groupedBy = params.context.pivot_row_groupby || this.data.groupedBy;
+ this.data.measures = this._processMeasures(params.context.pivot_measures) || this.data.measures;
+ this.defaultGroupedBy = this.data.groupedBy.length ? this.data.groupedBy : this.defaultGroupedBy;
+ }
+ if ('domain' in params) {
+ this.data.domain = params.domain;
+ this.initialDomain = params.domain;
+ } else {
+ this.data.domain = this.initialDomain;
+ }
+ if ('groupBy' in params) {
+ this.data.groupedBy = params.groupBy.length ? params.groupBy : this.defaultGroupedBy;
+ }
+ if ('timeRanges' in params) {
+ this.data.timeRanges = params.timeRanges;
+ }
+ this._computeDerivedParams();
+
+ this.data.groupedBy = this.data.groupedBy.slice();
+ this.data.rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys.slice();
+
+ if (!_.isEqual(oldRowGroupBys, self.data.rowGroupBys)) {
+ this.data.expandedRowGroupBys = [];
+ }
+ if (!_.isEqual(oldColGroupBys, self.data.colGroupBys)) {
+ this.data.expandedColGroupBys = [];
+ }
+
+ if ('measure' in params) {
+ return this._toggleMeasure(params.measure);
+ }
+
+ if (!this._hasData()) {
+ return this._loadData();
+ }
+
+ var oldRowGroupTree = this.rowGroupTree;
+ var oldColGroupTree = this.colGroupTree;
+ return this._loadData().then(function () {
+ if (_.isEqual(oldRowGroupBys, self.data.rowGroupBys)) {
+ self._pruneTree(self.rowGroupTree, oldRowGroupTree);
+ }
+ if (_.isEqual(oldColGroupBys, self.data.colGroupBys)) {
+ self._pruneTree(self.colGroupTree, oldColGroupTree);
+ }
+ });
+ },
+ /**
+ * Sort the rows, depending on the values of a given column. This is an
+ * in-memory sort.
+ *
+ * @param {Object} sortedColumn
+ * @param {number[]} sortedColumn.groupId
+ */
+ sortRows: function (sortedColumn) {
+ var self = this;
+ var colGroupValues = sortedColumn.groupId[1];
+ sortedColumn.originIndexes = sortedColumn.originIndexes || [0];
+ this.data.sortedColumn = sortedColumn;
+
+ var sortFunction = function (tree) {
+ return function (subTreeKey) {
+ var subTree = tree.directSubTrees.get(subTreeKey);
+ var groupIntersectionId = [subTree.root.values, colGroupValues];
+ var value = self._getCellValue(
+ groupIntersectionId,
+ sortedColumn.measure,
+ sortedColumn.originIndexes
+ ) || 0;
+ return sortedColumn.order === 'asc' ? value : -value;
+ };
+ };
+
+ this._sortTree(sortFunction, this.rowGroupTree);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add labels/values in the provided groupTree. A new leaf is created in
+ * the groupTree with a root object corresponding to the group with given
+ * labels/values.
+ *
+ * @private
+ * @param {Object} groupTree, either this.rowGroupTree or this.colGroupTree
+ * @param {string[]} labels
+ * @param {Array} values
+ */
+ _addGroup: function (groupTree, labels, values) {
+ var tree = groupTree;
+ // we assume here that the group with value value.slice(value.length - 2) has already been added.
+ values.slice(0, values.length - 1).forEach(function (value) {
+ tree = tree.directSubTrees.get(value);
+ });
+ tree.directSubTrees.set(values[values.length - 1], {
+ root: {
+ labels: labels,
+ values: values,
+ },
+ directSubTrees: new Map(),
+ });
+ },
+ /**
+ * Compute what should be used as rowGroupBys by the pivot view
+ *
+ * @private
+ * @returns {string[]}
+ */
+ _computeRowGroupBys: function () {
+ return !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys;
+ },
+ /**
+ * Find a group with given values in the provided groupTree, either
+ * this.rowGrouptree or this.colGroupTree.
+ *
+ * @private
+ * @param {Object} groupTree
+ * @param {Array} values
+ * @returns {Object}
+ */
+ _findGroup: function (groupTree, values) {
+ var tree = groupTree;
+ values.slice(0, values.length).forEach(function (value) {
+ tree = tree.directSubTrees.get(value);
+ });
+ return tree;
+ },
+ /**
+ * In case originIndex is an array of length 1, thus a single origin
+ * index, returns the given measure for a group determined by the id
+ * groupId and the origin index.
+ * If originIndexes is an array of length 2, we compute the variation
+ * ot the measure values for the groups determined by groupId and the
+ * different origin indexes.
+ *
+ * @private
+ * @param {Array[]} groupId
+ * @param {string} measure
+ * @param {number[]} originIndexes
+ * @returns {number}
+ */
+ _getCellValue: function (groupId, measure, originIndexes) {
+ var self = this;
+ var key = JSON.stringify(groupId);
+ if (!self.measurements[key]) {
+ return;
+ }
+ var values = originIndexes.map(function (originIndex) {
+ return self.measurements[key][originIndex][measure];
+ });
+ if (originIndexes.length > 1) {
+ return computeVariation(values[1], values[0]);
+ } else {
+ return values[0];
+ }
+ },
+ /**
+ * Returns the rowGroupBys and colGroupBys arrays that
+ * are actually used by the pivot view internally
+ * (for read_group or other purpose)
+ *
+ * @private
+ * @returns {Object} with keys colGroupBys and rowGroupBys
+ */
+ _getGroupBys: function () {
+ return {
+ colGroupBys: this.data.colGroupBys.concat(this.data.expandedColGroupBys),
+ rowGroupBys: this.data.rowGroupBys.concat(this.data.expandedRowGroupBys),
+ };
+ },
+ /**
+ * Returns a domain representation of a group
+ *
+ * @private
+ * @param {Object} group
+ * @param {Array} group.colValues
+ * @param {Array} group.rowValues
+ * @param {number} group.originIndex
+ * @returns {Array[]}
+ */
+ _getGroupDomain: function (group) {
+ var key = JSON.stringify([group.rowValues, group.colValues]);
+ return this.groupDomains[key][group.originIndex];
+ },
+ /**
+ * Returns the group sanitized labels.
+ *
+ * @private
+ * @param {Object} group
+ * @param {string[]} groupBys
+ * @returns {string[]}
+ */
+ _getGroupLabels: function (group, groupBys) {
+ var self = this;
+ return groupBys.map(function (groupBy) {
+ return self._sanitizeLabel(group[groupBy], groupBy);
+ });
+ },
+ /**
+ * Returns a promise that returns the annotated read_group results
+ * corresponding to a partition of the given group obtained using the given
+ * rowGroupBy and colGroupBy.
+ *
+ * @private
+ * @param {Object} group
+ * @param {string[]} rowGroupBy
+ * @param {string[]} colGroupBy
+ * @returns {Promise}
+ */
+ _getGroupSubdivision: function (group, rowGroupBy, colGroupBy) {
+ var groupDomain = this._getGroupDomain(group);
+ var measureSpecs = this._getMeasureSpecs();
+ var groupBy = rowGroupBy.concat(colGroupBy);
+ return this._rpc({
+ model: this.modelName,
+ method: 'read_group',
+ context: this.data.context,
+ domain: groupDomain,
+ fields: measureSpecs,
+ groupBy: groupBy,
+ lazy: false,
+ }).then(function (subGroups) {
+ return {
+ group: group,
+ subGroups: subGroups,
+ rowGroupBy: rowGroupBy,
+ colGroupBy: colGroupBy
+ };
+ });
+ },
+ /**
+ * Returns the group sanitized values.
+ *
+ * @private
+ * @param {Object} group
+ * @param {string[]} groupBys
+ * @returns {Array}
+ */
+ _getGroupValues: function (group, groupBys) {
+ var self = this;
+ return groupBys.map(function (groupBy) {
+ return self._sanitizeValue(group[groupBy]);
+ });
+ },
+ /**
+ * Returns the leaf counts of each group inside the given tree.
+ *
+ * @private
+ * @param {Object} tree
+ * @returns {Object} keys are group ids
+ */
+ _getLeafCounts: function (tree) {
+ var self = this;
+ var leafCounts = {};
+ var leafCount;
+ if (!tree.directSubTrees.size) {
+ leafCount = 1;
+ } else {
+ leafCount = [...tree.directSubTrees.values()].reduce(
+ function (acc, subTree) {
+ var subLeafCounts = self._getLeafCounts(subTree);
+ _.extend(leafCounts, subLeafCounts);
+ return acc + leafCounts[JSON.stringify(subTree.root.values)];
+ },
+ 0
+ );
+ }
+
+ leafCounts[JSON.stringify(tree.root.values)] = leafCount;
+ return leafCounts;
+ },
+ /**
+ * Returns the group sanitized measure values for the measures in
+ * this.data.measures (that migth contain '__count', not really a fieldName).
+ *
+ * @private
+ * @param {Object} group
+ * @returns {Array}
+ */
+ _getMeasurements: function (group) {
+ var self = this;
+ return this.data.measures.reduce(
+ function (measurements, fieldName) {
+ var measurement = group[fieldName];
+ if (measurement instanceof Array) {
+ // case field is many2one and used as measure and groupBy simultaneously
+ measurement = 1;
+ }
+ if (self.fields[fieldName].type === 'boolean' && measurement instanceof Boolean) {
+ measurement = measurement ? 1 : 0;
+ }
+ if (self.data.origins.length > 1 && !measurement) {
+ measurement = 0;
+ }
+ measurements[fieldName] = measurement;
+ return measurements;
+ },
+ {}
+ );
+ },
+ /**
+ * Returns a description of the measures row of the pivot table
+ *
+ * @private
+ * @param {Object[]} columns for which measure cells must be generated
+ * @returns {Object[]}
+ */
+ _getMeasuresRow: function (columns) {
+ var self = this;
+ var sortedColumn = this.data.sortedColumn || {};
+ var measureRow = [];
+
+ columns.forEach(function (column) {
+ self.data.measures.forEach(function (measure) {
+ var measureCell = {
+ groupId: column.groupId,
+ height: 1,
+ measure: measure,
+ title: self.fields[measure].string,
+ width: 2 * self.data.origins.length - 1,
+ };
+ if (sortedColumn.measure === measure &&
+ _.isEqual(sortedColumn.groupId, column.groupId)) {
+ measureCell.order = sortedColumn.order;
+ }
+ measureRow.push(measureCell);
+ });
+ });
+
+ return measureRow;
+ },
+ /**
+ * Returns the list of measure specs associated with data.measures, i.e.
+ * a measure 'fieldName' becomes 'fieldName:groupOperator' where
+ * groupOperator is the value specified on the field 'fieldName' for
+ * the key group_operator.
+ *
+ * @private
+ * @return {string[]}
+ */
+ _getMeasureSpecs: function () {
+ var self = this;
+ return this.data.measures.reduce(
+ function (acc, measure) {
+ if (measure === '__count') {
+ acc.push(measure);
+ return acc;
+ }
+ var type = self.fields[measure].type;
+ var groupOperator = self.fields[measure].group_operator;
+ if (type === 'many2one') {
+ groupOperator = 'count_distinct';
+ }
+ if (groupOperator === undefined) {
+ throw new Error("No aggregate function has been provided for the measure '" + measure + "'");
+ }
+ acc.push(measure + ':' + groupOperator);
+ return acc;
+ },
+ []
+ );
+ },
+ /**
+ * Make sure that the labels of different many2one values are distinguished
+ * by numbering them if necessary.
+ *
+ * @private
+ * @param {Array} label
+ * @param {string} fieldName
+ * @returns {string}
+ */
+ _getNumberedLabel: function (label, fieldName) {
+ var id = label[0];
+ var name = label[1];
+ this.numbering[fieldName] = this.numbering[fieldName] || {};
+ this.numbering[fieldName][name] = this.numbering[fieldName][name] || {};
+ var numbers = this.numbering[fieldName][name];
+ numbers[id] = numbers[id] || _.size(numbers) + 1;
+ return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : "");
+ },
+ /**
+ * Returns a description of the origins row of the pivot table
+ *
+ * @private
+ * @param {Object[]} columns for which origin cells must be generated
+ * @returns {Object[]}
+ */
+ _getOriginsRow: function (columns) {
+ var self = this;
+ var sortedColumn = this.data.sortedColumn || {};
+ var originRow = [];
+
+ columns.forEach(function (column) {
+ var groupId = column.groupId;
+ var measure = column.measure;
+ var isSorted = sortedColumn.measure === measure &&
+ _.isEqual(sortedColumn.groupId, groupId);
+ var isSortedByOrigin = isSorted && !sortedColumn.originIndexes[1];
+ var isSortedByVariation = isSorted && sortedColumn.originIndexes[1];
+
+ self.data.origins.forEach(function (origin, originIndex) {
+ var originCell = {
+ groupId: groupId,
+ height: 1,
+ measure: measure,
+ originIndexes: [originIndex],
+ title: origin,
+ width: 1,
+ };
+ if (isSortedByOrigin && sortedColumn.originIndexes[0] === originIndex) {
+ originCell.order = sortedColumn.order;
+ }
+ originRow.push(originCell);
+
+ if (originIndex > 0) {
+ var variationCell = {
+ groupId: groupId,
+ height: 1,
+ measure: measure,
+ originIndexes: [originIndex - 1, originIndex],
+ title: _t('Variation'),
+ width: 1,
+ };
+ if (isSortedByVariation && sortedColumn.originIndexes[1] === originIndex) {
+ variationCell.order = sortedColumn.order;
+ }
+ originRow.push(variationCell);
+ }
+
+ });
+ });
+
+ return originRow;
+ },
+
+ /**
+ * Get the selection needed to display the group by dropdown
+ * @returns {Object[]}
+ * @private
+ */
+ _getSelectionGroupBy: function (groupBys) {
+ let groupedFieldNames = groupBys.rowGroupBys
+ .concat(groupBys.colGroupBys)
+ .map(function (g) {
+ return g.split(':')[0];
+ });
+
+ var fields = Object.keys(this.groupableFields)
+ .map((fieldName, index) => {
+ return {
+ name: fieldName,
+ field: this.groupableFields[fieldName],
+ active: groupedFieldNames.includes(fieldName)
+ }
+ })
+ .sort((left, right) => left.field.string < right.field.string ? -1 : 1);
+ return fields;
+ },
+
+ /**
+ * Returns a description of the pivot table.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getTable: function () {
+ var headers = this._getTableHeaders();
+ return {
+ headers: headers,
+ rows: this._getTableRows(this.rowGroupTree, headers[headers.length - 1]),
+ };
+ },
+ /**
+ * Returns the list of header rows of the pivot table: the col group rows
+ * (depending on the col groupbys), the measures row and optionnaly the
+ * origins row (if there are more than one origins).
+ *
+ * @private
+ * @returns {Object[]}
+ */
+ _getTableHeaders: function () {
+ var colGroupBys = this._getGroupBys().colGroupBys;
+ var height = colGroupBys.length + 1;
+ var measureCount = this.data.measures.length;
+ var originCount = this.data.origins.length;
+ var leafCounts = this._getLeafCounts(this.colGroupTree);
+ var headers = [];
+ var measureColumns = []; // used to generate the measure cells
+
+ // 1) generate col group rows (total row + one row for each col groupby)
+ var colGroupRows = (new Array(height)).fill(0).map(function () {
+ return [];
+ });
+ // blank top left cell
+ colGroupRows[0].push({
+ height: height + 1 + (originCount > 1 ? 1 : 0), // + measures rows [+ origins row]
+ title: "",
+ width: 1,
+ });
+
+ // col groupby cells with group values
+ /**
+ * Recursive function that generates the header cells corresponding to
+ * the groups of a given tree.
+ *
+ * @param {Object} tree
+ */
+ function generateTreeHeaders(tree, fields) {
+ var group = tree.root;
+ var rowIndex = group.values.length;
+ var row = colGroupRows[rowIndex];
+ var groupId = [[], group.values];
+ var isLeaf = !tree.directSubTrees.size;
+ var leafCount = leafCounts[JSON.stringify(tree.root.values)];
+ var cell = {
+ groupId: groupId,
+ height: isLeaf ? (colGroupBys.length + 1 - rowIndex) : 1,
+ isLeaf: isLeaf,
+ label: rowIndex === 0 ? undefined : fields[colGroupBys[rowIndex - 1].split(':')[0]].string,
+ title: group.labels[group.labels.length - 1] || _t('Total'),
+ width: leafCount * measureCount * (2 * originCount - 1),
+ };
+ row.push(cell);
+ if (isLeaf) {
+ measureColumns.push(cell);
+ }
+
+ [...tree.directSubTrees.values()].forEach(function (subTree) {
+ generateTreeHeaders(subTree, fields);
+ });
+ }
+
+ generateTreeHeaders(this.colGroupTree, this.fields);
+ // blank top right cell for 'Total' group (if there is more that one leaf)
+ if (leafCounts[JSON.stringify(this.colGroupTree.root.values)] > 1) {
+ var groupId = [[], []];
+ var totalTopRightCell = {
+ groupId: groupId,
+ height: height,
+ title: "",
+ width: measureCount * (2 * originCount - 1),
+ };
+ colGroupRows[0].push(totalTopRightCell);
+ measureColumns.push(totalTopRightCell);
+ }
+ headers = headers.concat(colGroupRows);
+
+ // 2) generate measures row
+ var measuresRow = this._getMeasuresRow(measureColumns);
+ headers.push(measuresRow);
+
+ // 3) generate origins row if more than one origin
+ if (originCount > 1) {
+ headers.push(this._getOriginsRow(measuresRow));
+ }
+
+ return headers;
+ },
+ /**
+ * Returns the list of body rows of the pivot table for a given tree.
+ *
+ * @private
+ * @param {Object} tree
+ * @param {Object[]} columns
+ * @returns {Object[]}
+ */
+ _getTableRows: function (tree, columns) {
+ var self = this;
+
+ var rows = [];
+ var group = tree.root;
+ var rowGroupId = [group.values, []];
+ var title = group.labels[group.labels.length - 1] || _t('Total');
+ var indent = group.labels.length;
+ var isLeaf = !tree.directSubTrees.size;
+ var rowGroupBys = this._getGroupBys().rowGroupBys;
+
+ var subGroupMeasurements = columns.map(function (column) {
+ var colGroupId = column.groupId;
+ var groupIntersectionId = [rowGroupId[0], colGroupId[1]];
+ var measure = column.measure;
+ var originIndexes = column.originIndexes || [0];
+
+ var value = self._getCellValue(groupIntersectionId, measure, originIndexes);
+
+ var measurement = {
+ groupId: groupIntersectionId,
+ originIndexes: originIndexes,
+ measure: measure,
+ value: value,
+ isBold: !groupIntersectionId[0].length || !groupIntersectionId[1].length,
+ };
+ return measurement;
+ });
+
+ rows.push({
+ title: title,
+ label: indent === 0 ? undefined : this.fields[rowGroupBys[indent - 1].split(':')[0]].string,
+ groupId: rowGroupId,
+ indent: indent,
+ isLeaf: isLeaf,
+ subGroupMeasurements: subGroupMeasurements
+ });
+
+ var subTreeKeys = tree.sortedKeys || [...tree.directSubTrees.keys()];
+ subTreeKeys.forEach(function (subTreeKey) {
+ var subTree = tree.directSubTrees.get(subTreeKey);
+ rows = rows.concat(self._getTableRows(subTree, columns));
+ });
+
+ return rows;
+ },
+ /**
+ * returns the height of a given groupTree
+ *
+ * @private
+ * @param {Object} tree, a groupTree
+ * @returns {number}
+ */
+ _getTreeHeight: function (tree) {
+ var subTreeHeights = [...tree.directSubTrees.values()].map(this._getTreeHeight.bind(this));
+ return Math.max(0, Math.max.apply(null, subTreeHeights)) + 1;
+ },
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _hasData: function () {
+ return (this.counts[JSON.stringify([[], []])] || []).some(function (count) {
+ return count > 0;
+ });
+ },
+ /**
+ * @override
+ */
+ _isEmpty() {
+ return !this._hasData();
+ },
+ /**
+ * Initilize/Reinitialize this.rowGroupTree, colGroupTree, measurements,
+ * counts and subdivide the group 'Total' as many times it is necessary.
+ * A first subdivision with no groupBy (divisors.slice(0, 1)) is made in
+ * order to see if there is data in the intersection of the group 'Total'
+ * and the various origins. In case there is none, nonsupplementary rpc
+ * will be done (see the code of subdivideGroup).
+ * Once the promise resolves, this.rowGroupTree, colGroupTree,
+ * measurements, counts are correctly set.
+ *
+ * @private
+ * @return {Promise}
+ */
+ _loadData: function () {
+ var self = this;
+
+ this.rowGroupTree = { root: { labels: [], values: [] }, directSubTrees: new Map() };
+ this.colGroupTree = { root: { labels: [], values: [] }, directSubTrees: new Map() };
+ this.measurements = {};
+ this.counts = {};
+
+ var key = JSON.stringify([[], []]);
+ this.groupDomains = {};
+ this.groupDomains[key] = this.data.domains.slice(0);
+
+
+ var group = { rowValues: [], colValues: [] };
+ var groupBys = this._getGroupBys();
+ var leftDivisors = sections(groupBys.rowGroupBys);
+ var rightDivisors = sections(groupBys.colGroupBys);
+ var divisors = cartesian(leftDivisors, rightDivisors);
+
+ return this._subdivideGroup(group, divisors.slice(0, 1)).then(function () {
+ return self._subdivideGroup(group, divisors.slice(1));
+ });
+ },
+ /**
+ * Extract the information in the read_group results (groupSubdivisions)
+ * and develop this.rowGroupTree, colGroupTree, measurements, counts, and
+ * groupDomains.
+ * If a column needs to be sorted, the rowGroupTree corresponding to the
+ * group is sorted.
+ *
+ * @private
+ * @param {Object} group
+ * @param {Object[]} groupSubdivisions
+ */
+ _prepareData: function (group, groupSubdivisions) {
+ var self = this;
+
+ var groupRowValues = group.rowValues;
+ var groupRowLabels = [];
+ var rowSubTree = this.rowGroupTree;
+ var root;
+ if (groupRowValues.length) {
+ // we should have labels information on hand! regretful!
+ rowSubTree = this._findGroup(this.rowGroupTree, groupRowValues);
+ root = rowSubTree.root;
+ groupRowLabels = root.labels;
+ }
+
+ var groupColValues = group.colValues;
+ var groupColLabels = [];
+ if (groupColValues.length) {
+ root = this._findGroup(this.colGroupTree, groupColValues).root;
+ groupColLabels = root.labels;
+ }
+
+ groupSubdivisions.forEach(function (groupSubdivision) {
+ groupSubdivision.subGroups.forEach(function (subGroup) {
+
+ var rowValues = groupRowValues.concat(self._getGroupValues(subGroup, groupSubdivision.rowGroupBy));
+ var rowLabels = groupRowLabels.concat(self._getGroupLabels(subGroup, groupSubdivision.rowGroupBy));
+
+ var colValues = groupColValues.concat(self._getGroupValues(subGroup, groupSubdivision.colGroupBy));
+ var colLabels = groupColLabels.concat(self._getGroupLabels(subGroup, groupSubdivision.colGroupBy));
+
+ if (!colValues.length && rowValues.length) {
+ self._addGroup(self.rowGroupTree, rowLabels, rowValues);
+ }
+ if (colValues.length && !rowValues.length) {
+ self._addGroup(self.colGroupTree, colLabels, colValues);
+ }
+
+ var key = JSON.stringify([rowValues, colValues]);
+ var originIndex = groupSubdivision.group.originIndex;
+
+ if (!(key in self.measurements)) {
+ self.measurements[key] = self.data.origins.map(function () {
+ return self._getMeasurements({});
+ });
+ }
+ self.measurements[key][originIndex] = self._getMeasurements(subGroup);
+
+ if (!(key in self.counts)) {
+ self.counts[key] = self.data.origins.map(function () {
+ return 0;
+ });
+ }
+ self.counts[key][originIndex] = subGroup.__count;
+
+ if (!(key in self.groupDomains)) {
+ self.groupDomains[key] = self.data.origins.map(function () {
+ return Domain.FALSE_DOMAIN;
+ });
+ }
+ // if __domain is not defined this means that we are in the
+ // case where
+ // groupSubdivision.rowGroupBy = groupSubdivision.rowGroupBy = []
+ if (subGroup.__domain) {
+ self.groupDomains[key][originIndex] = subGroup.__domain;
+ }
+ });
+ });
+
+ if (this.data.sortedColumn) {
+ this.sortRows(this.data.sortedColumn, rowSubTree);
+ }
+ },
+ /**
+ * In the preview implementation of the pivot view (a.k.a. version 2),
+ * the virtual field used to display the number of records was named
+ * __count__, whereas __count is actually the one used in xml. So
+ * basically, activating a filter specifying __count as measures crashed.
+ * Unfortunately, as __count__ was used in the JS, all filters saved as
+ * favorite at that time were saved with __count__, and not __count.
+ * So in order the make them still work with the new implementation, we
+ * handle both __count__ and __count.
+ *
+ * This function replaces in the given array of measures occurences of
+ * '__count__' by '__count'.
+ *
+ * @private
+ * @param {Array[string] || undefined} measures
+ * @returns {Array[string] || undefined}
+ */
+ _processMeasures: function (measures) {
+ if (measures) {
+ return _.map(measures, function (measure) {
+ return measure === '__count__' ? '__count' : measure;
+ });
+ }
+ },
+ /**
+ * Determine this.data.domains and this.data.origins from
+ * this.data.domain and this.data.timeRanges;
+ *
+ * @private
+ */
+ _computeDerivedParams: function () {
+ const { range, rangeDescription, comparisonRange, comparisonRangeDescription } = this.data.timeRanges;
+ if (range) {
+ this.data.domains = [this.data.domain.concat(comparisonRange), this.data.domain.concat(range)];
+ this.data.origins = [comparisonRangeDescription, rangeDescription];
+ } else {
+ this.data.domains = [this.data.domain];
+ this.data.origins = [""];
+ }
+ },
+ /**
+ * Make any group in tree a leaf if it was a leaf in oldTree.
+ *
+ * @private
+ * @param {Object} tree
+ * @param {Object} oldTree
+ */
+ _pruneTree: function (tree, oldTree) {
+ if (!oldTree.directSubTrees.size) {
+ tree.directSubTrees.clear();
+ delete tree.sortedKeys;
+ return;
+ }
+ var self = this;
+ [...tree.directSubTrees.keys()].forEach(function (subTreeKey) {
+ var subTree = tree.directSubTrees.get(subTreeKey);
+ if (!oldTree.directSubTrees.has(subTreeKey)) {
+ subTree.directSubTrees.clear();
+ delete subTreeKey.sortedKeys;
+ } else {
+ var oldSubTree = oldTree.directSubTrees.get(subTreeKey);
+ self._pruneTree(subTree, oldSubTree);
+ }
+ });
+ },
+ /**
+ * Toggle the active state for a given measure, then reload the data
+ * if this turns out to be necessary.
+ *
+ * @param {string} fieldName
+ * @returns {Promise}
+ */
+ _toggleMeasure: function (fieldName) {
+ var index = this.data.measures.indexOf(fieldName);
+ if (index !== -1) {
+ this.data.measures.splice(index, 1);
+ // in this case, we already have all data in memory, no need to
+ // actually reload a lesser amount of information
+ return Promise.resolve();
+ } else {
+ this.data.measures.push(fieldName);
+ }
+ return this._loadData();
+ },
+ /**
+ * Extract from a groupBy value a label.
+ *
+ * @private
+ * @param {any} value
+ * @param {string} groupBy
+ * @returns {string}
+ */
+ _sanitizeLabel: function (value, groupBy) {
+ var fieldName = groupBy.split(':')[0];
+ if (value === false) {
+ return _t("Undefined");
+ }
+ if (value instanceof Array) {
+ return this._getNumberedLabel(value, fieldName);
+ }
+ if (fieldName && this.fields[fieldName] && (this.fields[fieldName].type === 'selection')) {
+ var selected = _.where(this.fields[fieldName].selection, { 0: value })[0];
+ return selected ? selected[1] : value;
+ }
+ return value;
+ },
+ /**
+ * Extract from a groupBy value the raw value of that groupBy (discarding
+ * a label if any)
+ *
+ * @private
+ * @param {any} value
+ * @returns {any}
+ */
+ _sanitizeValue: function (value) {
+ if (value instanceof Array) {
+ return value[0];
+ }
+ return value;
+ },
+ /**
+ * Get all partitions of a given group using the provided list of divisors
+ * and enrich the objects of this.rowGroupTree, colGroupTree,
+ * measurements, counts.
+ *
+ * @private
+ * @param {Object} group
+ * @param {Array[]} divisors
+ * @returns
+ */
+ _subdivideGroup: function (group, divisors) {
+ var self = this;
+
+ var key = JSON.stringify([group.rowValues, group.colValues]);
+
+ var proms = this.data.origins.reduce(
+ function (acc, origin, originIndex) {
+ // if no information on group content is available, we fetch data.
+ // if group is known to be empty for the given origin,
+ // we don't need to fetch data fot that origin.
+ if (!self.counts[key] || self.counts[key][originIndex] > 0) {
+ var subGroup = {
+ rowValues: group.rowValues,
+ colValues: group.colValues,
+ originIndex: originIndex
+ };
+ divisors.forEach(function (divisor) {
+ acc.push(self._getGroupSubdivision(subGroup, divisor[0], divisor[1]));
+ });
+ }
+ return acc;
+ },
+ []
+ );
+ return this._loadDataDropPrevious.add(Promise.all(proms)).then(function (groupSubdivisions) {
+ if (groupSubdivisions.length) {
+ self._prepareData(group, groupSubdivisions);
+ }
+ });
+ },
+ /**
+ * Sort recursively the subTrees of tree using sortFunction.
+ * In the end each node of the tree has its direct children sorted
+ * according to the criterion reprensented by sortFunction.
+ *
+ * @private
+ * @param {Function} sortFunction
+ * @param {Object} tree
+ */
+ _sortTree: function (sortFunction, tree) {
+ var self = this;
+ tree.sortedKeys = _.sortBy([...tree.directSubTrees.keys()], sortFunction(tree));
+ [...tree.directSubTrees.values()].forEach(function (subTree) {
+ self._sortTree(sortFunction, subTree);
+ });
+ },
+});
+
+return PivotModel;
+
+});
diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js
new file mode 100644
index 00000000..dcba95bc
--- /dev/null
+++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js
@@ -0,0 +1,202 @@
+odoo.define('web.PivotRenderer', function (require) {
+ "use strict";
+
+ const OwlAbstractRenderer = require('web.AbstractRendererOwl');
+ const field_utils = require('web.field_utils');
+ const patchMixin = require('web.patchMixin');
+
+ const { useExternalListener, useState, onMounted, onPatched } = owl.hooks;
+
+ /**
+ * Here is a basic example of the structure of the Pivot Table:
+ *
+ * ┌─────────────────────────┬─────────────────────────────────────────────┬─────────────────┐
+ * │ │ - web.PivotHeader │ │
+ * │ ├──────────────────────┬──────────────────────┤ │
+ * │ │ + web.PivotHeader │ + web.PivotHeader │ │
+ * ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤
+ * │ │ web.PivotMeasure │ web.PivotMeasure │ │
+ * ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤
+ * │ ─ web.PivotHeader │ │ │ │
+ * ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤
+ * │ + web.PivotHeader │ │ │ │
+ * ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤
+ * │ + web.PivotHeader │ │ │ │
+ * └─────────────────────────┴──────────────────────┴──────────────────────┴─────────────────┘
+ *
+ */
+
+ class PivotRenderer extends OwlAbstractRenderer {
+ /**
+ * @override
+ * @param {boolean} props.disableLinking Disallow opening records by clicking on a cell
+ * @param {Object} props.widgets Widgets defined in the arch
+ */
+ constructor() {
+ super(...arguments);
+ this.sampleDataTargets = ['table'];
+ this.state = useState({
+ activeNodeHeader: {
+ groupId: false,
+ isXAxis: false,
+ click: false
+ },
+ });
+
+ onMounted(() => this._updateTooltip());
+
+ onPatched(() => this._updateTooltip());
+
+ useExternalListener(window, 'click', this._resetState);
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * Get the formatted value of the cell
+ *
+ * @private
+ * @param {Object} cell
+ * @returns {string} Formatted value
+ */
+ _getFormattedValue(cell) {
+ const type = this.props.widgets[cell.measure] ||
+ (this.props.fields[cell.measure].type === 'many2one' ? 'integer' : this.props.fields[cell.measure].type);
+ const formatter = field_utils.format[type];
+ return formatter(cell.value, this.props.fields[cell.measure]);
+ }
+
+ /**
+ * Get the formatted variation of a cell
+ *
+ * @private
+ * @param {Object} cell
+ * @returns {string} Formatted variation
+ */
+ _getFormattedVariation(cell) {
+ const value = cell.value;
+ return isNaN(value) ? '-' : field_utils.format.percentage(value, this.props.fields[cell.measure]);
+ }
+
+ /**
+ * Retrieves the padding of a left header
+ *
+ * @private
+ * @param {Object} cell
+ * @returns {Number} Padding
+ */
+ _getPadding(cell) {
+ return 5 + cell.indent * 30;
+ }
+
+ /**
+ * Compute if a cell is active (with its groupId)
+ *
+ * @private
+ * @param {Array} groupId GroupId of a cell
+ * @param {Boolean} isXAxis true if the cell is on the x axis
+ * @returns {Boolean} true if the cell is active
+ */
+ _isClicked(groupId, isXAxis) {
+ return _.isEqual(groupId, this.state.activeNodeHeader.groupId) && this.state.activeNodeHeader.isXAxis === isXAxis;
+ }
+
+ /**
+ * Reset the state of the node.
+ *
+ * @private
+ */
+ _resetState() {
+ // This check is used to avoid the destruction of the dropdown.
+ // The click on the header bubbles to window in order to hide
+ // all the other dropdowns (in this component or other components).
+ // So we need isHeaderClicked to cancel this behaviour.
+ if (this.isHeaderClicked) {
+ this.isHeaderClicked = false;
+ return;
+ }
+ this.state.activeNodeHeader = {
+ groupId: false,
+ isXAxis: false,
+ click: false
+ };
+ }
+
+ /**
+ * Configure the tooltips on the headers.
+ *
+ * @private
+ */
+ _updateTooltip() {
+ $(this.el).find('.o_pivot_header_cell_opened, .o_pivot_header_cell_closed').tooltip();
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+
+ /**
+ * Handles a click on a menu item in the dropdown to select a groupby.
+ *
+ * @private
+ * @param {Object} field
+ * @param {string} interval
+ */
+ _onClickMenuGroupBy(field, interval) {
+ this.trigger('groupby_menu_selection', { field, interval });
+ }
+
+
+ /**
+ * Handles a click on a header node
+ *
+ * @private
+ * @param {Object} cell
+ * @param {string} type col or row
+ */
+ _onHeaderClick(cell, type) {
+ const groupValues = cell.groupId[type === 'col' ? 1 : 0];
+ const groupByLength = type === 'col' ? this.props.colGroupBys.length : this.props.rowGroupBys.length;
+ if (cell.isLeaf && groupValues.length >= groupByLength) {
+ this.isHeaderClicked = true;
+ this.state.activeNodeHeader = {
+ groupId: cell.groupId,
+ isXAxis: type === 'col',
+ click: 'leftClick'
+ };
+ }
+ this.trigger(cell.isLeaf ? 'closed_header_click' : 'opened_header_click', { cell, type });
+ }
+
+ /**
+ * Hover the column in which the mouse is.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMouseEnter(ev) {
+ var index = [...ev.currentTarget.parentNode.children].indexOf(ev.currentTarget);
+ if (ev.currentTarget.tagName === 'TH') {
+ index += 1;
+ }
+ this.el.querySelectorAll('td:nth-child(' + (index + 1) + ')').forEach(elt => elt.classList.add('o_cell_hover'));
+ }
+
+ /**
+ * Remove the hover on the columns.
+ *
+ * @private
+ */
+ _onMouseLeave() {
+ this.el.querySelectorAll('.o_cell_hover').forEach(elt => elt.classList.remove('o_cell_hover'));
+ }
+ }
+
+ PivotRenderer.template = 'web.PivotRenderer';
+
+ return patchMixin(PivotRenderer);
+
+});
diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js
new file mode 100644
index 00000000..ea3ab9c7
--- /dev/null
+++ b/addons/web/static/src/js/views/pivot/pivot_view.js
@@ -0,0 +1,158 @@
+odoo.define('web.PivotView', function (require) {
+ "use strict";
+
+ /**
+ * The Pivot View is a view that represents data in a 'pivot grid' form. It
+ * aggregates data on 2 dimensions and displays the result, allows the user to
+ * 'zoom in' data.
+ */
+
+ const AbstractView = require('web.AbstractView');
+ const config = require('web.config');
+ const core = require('web.core');
+ const PivotModel = require('web.PivotModel');
+ const PivotController = require('web.PivotController');
+ const PivotRenderer = require('web.PivotRenderer');
+ const RendererWrapper = require('web.RendererWrapper');
+
+ const _t = core._t;
+ const _lt = core._lt;
+
+ const searchUtils = require('web.searchUtils');
+ const GROUPABLE_TYPES = searchUtils.GROUPABLE_TYPES;
+
+ const PivotView = AbstractView.extend({
+ display_name: _lt('Pivot'),
+ icon: 'fa-table',
+ config: Object.assign({}, AbstractView.prototype.config, {
+ Model: PivotModel,
+ Controller: PivotController,
+ Renderer: PivotRenderer,
+ }),
+ viewType: 'pivot',
+ searchMenuTypes: ['filter', 'groupBy', 'comparison', 'favorite'],
+
+ /**
+ * @override
+ * @param {Object} params
+ * @param {Array} params.additionalMeasures
+ */
+ init: function (viewInfo, params) {
+ this._super.apply(this, arguments);
+
+ const activeMeasures = []; // Store the defined active measures
+ const colGroupBys = []; // Store the defined group_by used on cols
+ const rowGroupBys = []; // Store the defined group_by used on rows
+ const measures = {}; // All the available measures
+ const groupableFields = {}; // The fields which can be used to group data
+ const widgets = {}; // Wigdets defined in the arch
+ const additionalMeasures = params.additionalMeasures || [];
+
+ this.fields.__count = { string: _t("Count"), type: "integer" };
+
+ //Compute the measures and the groupableFields
+ Object.keys(this.fields).forEach(name => {
+ const field = this.fields[name];
+ if (name !== 'id' && field.store === true) {
+ if (['integer', 'float', 'monetary'].includes(field.type) || additionalMeasures.includes(name)) {
+ measures[name] = field;
+ }
+ if (GROUPABLE_TYPES.includes(field.type)) {
+ groupableFields[name] = field;
+ }
+ }
+ });
+ measures.__count = { string: _t("Count"), type: "integer" };
+
+
+ this.arch.children.forEach(field => {
+ let name = field.attrs.name;
+ // Remove invisible fields from the measures if not in additionalMeasures
+ if (field.attrs.invisible && py.eval(field.attrs.invisible)) {
+ if (name in groupableFields) {
+ delete groupableFields[name];
+ }
+ if (!additionalMeasures.includes(name)) {
+ delete measures[name];
+ return;
+ }
+ }
+ if (field.attrs.interval) {
+ name += ':' + field.attrs.interval;
+ }
+ if (field.attrs.widget) {
+ widgets[name] = field.attrs.widget;
+ }
+ // add active measures to the measure list. This is very rarely
+ // necessary, but it can be useful if one is working with a
+ // functional field non stored, but in a model with an overrided
+ // read_group method. In this case, the pivot view could work, and
+ // the measure should be allowed. However, be careful if you define
+ // a measure in your pivot view: non stored functional fields will
+ // probably not work (their aggregate will always be 0).
+ if (field.attrs.type === 'measure' && !(name in measures)) {
+ measures[name] = this.fields[name];
+ }
+ if (field.attrs.string && name in measures) {
+ measures[name].string = field.attrs.string;
+ }
+ if (field.attrs.type === 'measure' || 'operator' in field.attrs) {
+ activeMeasures.push(name);
+ measures[name] = this.fields[name];
+ }
+ if (field.attrs.type === 'col') {
+ colGroupBys.push(name);
+ }
+ if (field.attrs.type === 'row') {
+ rowGroupBys.push(name);
+ }
+ });
+ if ((!activeMeasures.length) || this.arch.attrs.display_quantity) {
+ activeMeasures.splice(0, 0, '__count');
+ }
+
+ this.loadParams.measures = activeMeasures;
+ this.loadParams.colGroupBys = config.device.isMobile ? [] : colGroupBys;
+ this.loadParams.rowGroupBys = rowGroupBys;
+ this.loadParams.fields = this.fields;
+ this.loadParams.default_order = params.default_order || this.arch.attrs.default_order;
+ this.loadParams.groupableFields = groupableFields;
+
+ const disableLinking = !!(this.arch.attrs.disable_linking &&
+ JSON.stringify(this.arch.attrs.disable_linking));
+
+ this.rendererParams.widgets = widgets;
+ this.rendererParams.disableLinking = disableLinking;
+
+ this.controllerParams.disableLinking = disableLinking;
+ this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled");
+ this.controllerParams.measures = measures;
+
+ // retrieve form and list view ids from the action to open those views
+ // when a data cell of the pivot view is clicked
+ this.controllerParams.views = [
+ _findView(params.actionViews, 'list'),
+ _findView(params.actionViews, 'form'),
+ ];
+
+ function _findView(views, viewType) {
+ const view = views.find(view => {
+ return view.type === viewType;
+ });
+ return [view ? view.viewID : false, viewType];
+ }
+ },
+
+ /**
+ *
+ * @override
+ */
+ getRenderer(parent, state) {
+ state = Object.assign(state || {}, this.rendererParams);
+ return new RendererWrapper(parent, this.config.Renderer, state);
+ },
+ });
+
+ return PivotView;
+
+});
diff --git a/addons/web/static/src/js/views/qweb/qweb_view.js b/addons/web/static/src/js/views/qweb/qweb_view.js
new file mode 100644
index 00000000..4e7f8024
--- /dev/null
+++ b/addons/web/static/src/js/views/qweb/qweb_view.js
@@ -0,0 +1,208 @@
+/**
+ * Client-side implementation of a qweb view.
+ */
+odoo.define('web.qweb', function (require) {
+"use strict";
+
+var core = require('web.core');
+var AbstractView = require('web.AbstractView');
+var AbstractModel = require('web.AbstractModel');
+var AbstractRenderer = require('web.AbstractRenderer');
+var AbstractController = require('web.AbstractController');
+var registry = require('web.view_registry');
+
+var _lt = core._lt;
+
+/**
+ * model
+ */
+var Model = AbstractModel.extend({
+ /**
+ * init
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._state = {
+ viewId: false,
+ modelName: false,
+ body: '',
+ context: {},
+ domain: [],
+ };
+ },
+ /**
+ * fetches the rendered qweb view
+ */
+ _fetch: function () {
+ var state = this._state;
+ return this._rpc({
+ model: state.modelName,
+ method: 'qweb_render_view',
+ kwargs: {
+ view_id: state.viewId,
+ domain: state.domain,
+ context: state.context
+ }
+ }).then(function (r) {
+ state.body = r;
+ return state.viewId;
+ });
+ },
+ /**
+ * get
+ */
+ __get: function () {
+ return this._state;
+ },
+ /**
+ * load
+ */
+ __load: function (params) {
+ _.extend(this._state, _.pick(params, ['viewId', 'modelName', 'domain', 'context']));
+
+ return this._fetch();
+ },
+ /**
+ * reload
+ */
+ __reload: function (_id, params) {
+ _.extend(this._state, _.pick(params, ['domain', 'context']));
+
+ return this._fetch();
+ }
+});
+/**
+ * renderer
+ */
+var Renderer = AbstractRenderer.extend({
+ /**
+ * render
+ */
+ _render: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ self.$el.html(self.state.body);
+ });
+ }
+});
+/**
+ * controller
+ */
+var Controller = AbstractController.extend({
+ events: _.extend({}, AbstractController.prototype.events, {
+ 'click [type="toggle"]': '_onLazyToggle',
+ 'click [type="action"]' : '_onActionClicked',
+ }),
+
+ init: function () {
+ this._super.apply(this, arguments);
+ },
+
+ /**
+ * @override
+ */
+ renderButtons: function ($node) {
+ this.$buttons = $('<nav/>');
+ if ($node) {
+ $node.append(this.$buttons);
+ }
+ },
+ _update: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ // move control panel buttons from the view to the control panel
+ // area
+ var $cp_buttons = self.renderer.$('nav.o_qweb_cp_buttons');
+ $cp_buttons.children().appendTo(self.$buttons.empty());
+ $cp_buttons.remove();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Lazy toggle. Content is not remembered between unfolds.
+ */
+ _onLazyToggle: function (e) {
+ // TODO: add support for view (possibly action as well?)
+ var $target = $(e.target);
+ var $t = $target.closest('[data-model]');
+ if (!($target.hasClass('fa-caret-down') || $target.hasClass('fa-caret-right'))) {
+ $target = $t.find('.fa-caret-down, .fa-caret-right');
+ }
+
+ var data = $t.data();
+ if (this._fold($t)) {
+ $target.removeClass('fa-caret-down').addClass('fa-caret-right');
+ return;
+ }
+
+ // NB: $.data() automatically parses json attributes, but does not
+ // automatically parse lone float literals in data-*, so a
+ // data-args (as a json object) is very convenient
+ var args = data.args || _.omit(data, 'model', 'method', 'id');
+
+ return this._rpc({
+ model: data.model,
+ method: data.method,
+ args: data.id ? [data.id] : undefined,
+ kwargs: args // FIXME: context?
+ }).then(function (s) {
+ return $(s);
+ }).then(function ($newcontent) {
+ $t.data('children', $newcontent).after($newcontent);
+ $target.removeClass('fa-caret-right').addClass('fa-caret-down');
+ });
+ },
+ /**
+ * Attempts to fold the parameter, returns whether that happened.
+ */
+ _fold: function ($el) {
+ var $children = $el.data('children');
+ if (!$children) {
+ return false;
+ }
+
+ var self = this;
+ $children.each(function (_i, e) {
+ self._fold($(e));
+ }).remove();
+ $el.removeData('children');
+ return true;
+ }
+});
+
+/**
+ * view
+ */
+var QWebView = AbstractView.extend({
+ display_name: _lt('Freedom View'),
+ icon: 'fa-file-picture-o',
+ viewType: 'qweb',
+ // groupable?
+ enableTimeRangeMenu: true,
+ config: _.extend({}, AbstractView.prototype.config, {
+ Model: Model,
+ Renderer: Renderer,
+ Controller: Controller,
+ }),
+
+ /**
+ * init method
+ */
+ init: function (viewInfo, params) {
+ this._super.apply(this, arguments);
+ this.loadParams.viewId = viewInfo.view_id;
+ }
+});
+
+registry.add('qweb', QWebView);
+return {
+ View: QWebView,
+ Controller: Controller,
+ Renderer: Renderer,
+ Model: Model
+};
+});
diff --git a/addons/web/static/src/js/views/renderer_wrapper.js b/addons/web/static/src/js/views/renderer_wrapper.js
new file mode 100644
index 00000000..d8fd3843
--- /dev/null
+++ b/addons/web/static/src/js/views/renderer_wrapper.js
@@ -0,0 +1,15 @@
+odoo.define('web.RendererWrapper', function (require) {
+ "use strict";
+
+ const { ComponentWrapper } = require('web.OwlCompatibility');
+
+ class RendererWrapper extends ComponentWrapper {
+ getLocalState() { }
+ setLocalState() { }
+ giveFocus() { }
+ resetLocalState() { }
+ }
+
+ return RendererWrapper;
+
+});
diff --git a/addons/web/static/src/js/views/sample_server.js b/addons/web/static/src/js/views/sample_server.js
new file mode 100644
index 00000000..8af27feb
--- /dev/null
+++ b/addons/web/static/src/js/views/sample_server.js
@@ -0,0 +1,692 @@
+odoo.define('web.SampleServer', function (require) {
+ "use strict";
+
+ const session = require('web.session');
+ const utils = require('web.utils');
+ const Registry = require('web.Registry');
+
+ class UnimplementedRouteError extends Error {}
+
+ /**
+ * Helper function returning the value from a list of sample strings
+ * corresponding to the given ID.
+ * @param {number} id
+ * @param {string[]} sampleTexts
+ * @returns {string}
+ */
+ function getSampleFromId(id, sampleTexts) {
+ return sampleTexts[(id - 1) % sampleTexts.length];
+ }
+
+ /**
+ * Helper function returning a regular expression specifically matching
+ * a given 'term' in a fieldName. For example `fieldNameRegex('abc')`:
+ * will match:
+ * - "abc"
+ * - "field_abc__def"
+ * will not match:
+ * - "aabc"
+ * - "abcd_ef"
+ * @param {...string} term
+ * @returns {RegExp}
+ */
+ function fieldNameRegex(...terms) {
+ return new RegExp(`\\b((\\w+)?_)?(${terms.join('|')})(_(\\w+)?)?\\b`);
+ }
+
+ const DESCRIPTION_REGEX = fieldNameRegex('description', 'label', 'title', 'subject', 'message');
+ const EMAIL_REGEX = fieldNameRegex('email');
+ const PHONE_REGEX = fieldNameRegex('phone');
+ const URL_REGEX = fieldNameRegex('url');
+
+ /**
+ * Sample server class
+ *
+ * Represents a static instance of the server used when a RPC call sends
+ * empty values/groups while the attribute 'sample' is set to true on the
+ * view.
+ *
+ * This server will generate fake data and send them in the adequate format
+ * according to the route/method used in the RPC.
+ */
+ class SampleServer {
+
+ /**
+ * @param {string} modelName
+ * @param {Object} fields
+ */
+ constructor(modelName, fields) {
+ this.mainModel = modelName;
+ this.data = {};
+ this.data[modelName] = {
+ fields,
+ records: [],
+ };
+ // Generate relational fields' co models
+ for (const fieldName in fields) {
+ const field = fields[fieldName];
+ if (['many2one', 'one2many', 'many2many'].includes(field.type)) {
+ this.data[field.relation] = this.data[field.relation] || {
+ fields: {
+ display_name: { type: 'char' },
+ id: { type: 'integer' },
+ color: { type: 'integer' },
+ },
+ records: [],
+ };
+ }
+ }
+ // On some models, empty grouped Kanban or List view still contain
+ // real (empty) groups. In this case, we re-use the result of the
+ // web_read_group rpc to tweak sample data s.t. those real groups
+ // contain sample records.
+ this.existingGroups = null;
+ // Sample records generation is only done if necessary, so we delay
+ // it to the first "mockRPC" call. These flags allow us to know if
+ // the records have been generated or not.
+ this.populated = false;
+ this.existingGroupsPopulated = false;
+ }
+
+ //---------------------------------------------------------------------
+ // Public
+ //---------------------------------------------------------------------
+
+ /**
+ * This is the main entry point of the SampleServer. Mocks a request to
+ * the server with sample data.
+ * @param {Object} params
+ * @returns {any} the result obtained with the sample data
+ * @throws {Error} If called on a route/method we do not handle
+ */
+ mockRpc(params) {
+ if (!(params.model in this.data)) {
+ throw new Error(`SampleServer: unknown model ${params.model}`);
+ }
+ this._populateModels();
+ switch (params.method || params.route) {
+ case '/web/dataset/search_read':
+ return this._mockSearchReadController(params);
+ case 'web_read_group':
+ return this._mockWebReadGroup(params);
+ case 'read_group':
+ return this._mockReadGroup(params);
+ case 'read_progress_bar':
+ return this._mockReadProgressBar(params);
+ case 'read':
+ return this._mockRead(params);
+ }
+ // this rpc can't be mocked by the SampleServer itself, so check if there is an handler
+ // in the mockRegistry: either specific for this model (with key 'model/method'), or
+ // global (with key 'method')
+ const method = params.method || params.route;
+ const mockFunction = SampleServer.mockRegistry.get(`${params.model}/${method}`) ||
+ SampleServer.mockRegistry.get(method);
+ if (mockFunction) {
+ return mockFunction.call(this, params);
+ }
+ console.log(`SampleServer: unimplemented route "${params.method || params.route}"`);
+ throw new SampleServer.UnimplementedRouteError();
+ }
+
+ setExistingGroups(groups) {
+ this.existingGroups = groups;
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * @param {Object[]} measures, each measure has the form { fieldName, type }
+ * @param {Object[]} records
+ * @returns {Object}
+ */
+ _aggregateFields(measures, records) {
+ const values = {};
+ for (const { fieldName, type } of measures) {
+ if (['float', 'integer', 'monetary'].includes(type)) {
+ if (records.length) {
+ let value = 0;
+ for (const record of records) {
+ value += record[fieldName];
+ }
+ values[fieldName] = this._sanitizeNumber(value);
+ } else {
+ values[fieldName] = null;
+ }
+ }
+ if (type === 'many2one') {
+ const ids = new Set(records.map(r => r[fieldName]));
+ values.fieldName = ids.size || null;
+ }
+ }
+ return values;
+ }
+
+ /**
+ * @param {any} value
+ * @param {Object} options
+ * @param {string} [options.interval]
+ * @param {string} [options.relation]
+ * @param {string} [options.type]
+ * @returns {any}
+ */
+ _formatValue(value, options) {
+ if (!value) {
+ return false;
+ }
+ const { type, interval, relation } = options;
+ if (['date', 'datetime'].includes(type)) {
+ const fmt = SampleServer.FORMATS[interval];
+ return moment(value).format(fmt);
+ } else if (type === 'many2one') {
+ const rec = this.data[relation].records.find(({id}) => id === value);
+ return [value, rec.display_name];
+ } else {
+ return value;
+ }
+ }
+
+ /**
+ * Generates field values based on heuristics according to field types
+ * and names.
+ *
+ * @private
+ * @param {string} modelName
+ * @param {string} fieldName
+ * @param {number} id the record id
+ * @returns {any} the field value
+ */
+ _generateFieldValue(modelName, fieldName, id) {
+ const field = this.data[modelName].fields[fieldName];
+ switch (field.type) {
+ case "boolean":
+ return fieldName === 'active' ? true : this._getRandomBool();
+ case "char":
+ case "text":
+ if (["display_name", "name"].includes(fieldName)) {
+ if (SampleServer.PEOPLE_MODELS.includes(modelName)) {
+ return getSampleFromId(id, SampleServer.SAMPLE_PEOPLE);
+ } else if (modelName === 'res.country') {
+ return getSampleFromId(id, SampleServer.SAMPLE_COUNTRIES);
+ }
+ }
+ if (fieldName === 'display_name') {
+ return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
+ } else if (["name", "reference"].includes(fieldName)) {
+ return `REF${String(id).padStart(4, '0')}`;
+ } else if (DESCRIPTION_REGEX.test(fieldName)) {
+ return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
+ } else if (EMAIL_REGEX.test(fieldName)) {
+ const emailName = getSampleFromId(id, SampleServer.SAMPLE_PEOPLE)
+ .replace(/ /, ".")
+ .toLowerCase();
+ return `${emailName}@sample.demo`;
+ } else if (PHONE_REGEX.test(fieldName)) {
+ return `+1 555 754 ${String(id).padStart(4, '0')}`;
+ } else if (URL_REGEX.test(fieldName)) {
+ return `http://sample${id}.com`;
+ }
+ return false;
+ case "date":
+ case "datetime": {
+ const format = field.type === "date" ?
+ "YYYY-MM-DD" :
+ "YYYY-MM-DD HH:mm:ss";
+ return this._getRandomDate(format);
+ }
+ case "float":
+ return this._getRandomFloat(SampleServer.MAX_FLOAT);
+ case "integer": {
+ let max = SampleServer.MAX_INTEGER;
+ if (fieldName.includes('color')) {
+ max = this._getRandomBool() ? SampleServer.MAX_COLOR_INT : 0;
+ }
+ return this._getRandomInt(max);
+ }
+ case "monetary":
+ return this._getRandomInt(SampleServer.MAX_MONETARY);
+ case "many2one":
+ if (field.relation === 'res.currency') {
+ return session.company_currency_id;
+ }
+ if (field.relation === 'ir.attachment') {
+ return false;
+ }
+ return this._getRandomSubRecordId();
+ case "one2many":
+ case "many2many": {
+ const ids = [this._getRandomSubRecordId(), this._getRandomSubRecordId()];
+ return [...new Set(ids)];
+ }
+ case "selection": {
+ // I hoped we wouldn't have to implement such special cases, but here it is.
+ // If this (mail) field is set, 'Warning' is displayed instead of the last
+ // activity, and we don't want to see a bunch of 'Warning's in a list. In the
+ // future, if we have to implement several special cases like that, we'll setup
+ // a proper hook to allow external modules to define extensions of this function.
+ // For now, as we have only one use case, I guess that doing it here is fine.
+ if (fieldName === 'activity_exception_decoration') {
+ return false;
+ }
+ if (field.selection.length > 0) {
+ return this._getRandomArrayEl(field.selection)[0];
+ }
+ return false;
+ }
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * @private
+ * @param {any[]} array
+ * @returns {any}
+ */
+ _getRandomArrayEl(array) {
+ return array[Math.floor(Math.random() * array.length)];
+ }
+
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _getRandomBool() {
+ return Math.random() < 0.5;
+ }
+
+ /**
+ * @private
+ * @param {string} format
+ * @returns {moment}
+ */
+ _getRandomDate(format) {
+ const delta = Math.floor(
+ (Math.random() - Math.random()) * SampleServer.DATE_DELTA
+ );
+ return new moment()
+ .add(delta, "hour")
+ .format(format);
+ }
+
+ /**
+ * @private
+ * @param {number} max
+ * @returns {number} float in [O, max[
+ */
+ _getRandomFloat(max) {
+ return this._sanitizeNumber(Math.random() * max);
+ }
+
+ /**
+ * @private
+ * @param {number} max
+ * @returns {number} int in [0, max[
+ */
+ _getRandomInt(max) {
+ return Math.floor(Math.random() * max);
+ }
+
+ /**
+ * @private
+ * @returns {number} id in [1, SUB_RECORDSET_SIZE]
+ */
+ _getRandomSubRecordId() {
+ return Math.floor(Math.random() * SampleServer.SUB_RECORDSET_SIZE) + 1;
+ }
+ /**
+ * Mocks calls to the read method.
+ * @private
+ * @param {Object} params
+ * @param {string} params.model
+ * @param {Array[]} params.args (args[0] is the list of ids, args[1] is
+ * the list of fields)
+ * @returns {Object[]}
+ */
+ _mockRead(params) {
+ const model = this.data[params.model];
+ const ids = params.args[0];
+ const fieldNames = params.args[1];
+ const records = [];
+ for (const r of model.records) {
+ if (!ids.includes(r.id)) {
+ continue;
+ }
+ const record = { id: r.id };
+ for (const fieldName of fieldNames) {
+ const field = model.fields[fieldName];
+ if (!field) {
+ record[fieldName] = false; // unknown field
+ } else if (field.type === 'many2one') {
+ const relModel = this.data[field.relation];
+ const relRecord = relModel.records.find(
+ relR => r[fieldName] === relR.id
+ );
+ record[fieldName] = relRecord ?
+ [relRecord.id, relRecord.display_name] :
+ false;
+ } else {
+ record[fieldName] = r[fieldName];
+ }
+ }
+ records.push(record);
+ }
+ return records;
+ }
+
+ /**
+ * Mocks calls to the read_group method.
+ *
+ * @param {Object} params
+ * @param {string} params.model
+ * @param {string[]} [params.fields] defaults to the list of all fields
+ * @param {string[]} params.groupBy
+ * @param {boolean} [params.lazy=true]
+ * @returns {Object[]} Object with keys groups and length
+ */
+ _mockReadGroup(params) {
+ const lazy = 'lazy' in params ? params.lazy : true;
+ const model = params.model;
+ const fields = this.data[model].fields;
+ const records = this.data[model].records;
+
+ const normalizedGroupBys = [];
+ let groupBy = [];
+ if (params.groupBy.length) {
+ groupBy = lazy ? [params.groupBy[0]] : params.groupBy;
+ }
+ for (const groupBySpec of groupBy) {
+ let [fieldName, interval] = groupBySpec.split(':');
+ interval = interval || 'month';
+ const { type, relation } = fields[fieldName];
+ if (type) {
+ const gb = { fieldName, type, interval, relation, alias: groupBySpec };
+ normalizedGroupBys.push(gb);
+ }
+ }
+ const groups = utils.groupBy(records, (record) => {
+ const vals = {};
+ for (const gb of normalizedGroupBys) {
+ const { fieldName, type } = gb;
+ let value;
+ if (['date', 'datetime'].includes(type)) {
+ value = this._formatValue(record[fieldName], gb);
+ } else {
+ value = record[fieldName];
+ }
+ vals[fieldName] = value;
+ }
+ return JSON.stringify(vals);
+ });
+ const measures = [];
+ for (const measureSpec of (params.fields || Object.keys(fields))) {
+ const [fieldName, aggregateFunction] = measureSpec.split(':');
+ const { type } = fields[fieldName];
+ if (!params.groupBy.includes(fieldName) && type &&
+ (type !== 'many2one' || aggregateFunction !== 'count_distinct')) {
+ measures.push({ fieldName, type });
+ }
+ }
+
+ let result = [];
+ for (const id in groups) {
+ const records = groups[id];
+ const group = { __domain: [] };
+ let countKey = `__count`;
+ if (normalizedGroupBys.length && lazy) {
+ countKey = `${normalizedGroupBys[0].fieldName}_count`;
+ }
+ group[countKey] = records.length;
+ const firstElem = records[0];
+ for (const gb of normalizedGroupBys) {
+ const { alias, fieldName } = gb;
+ group[alias] = this._formatValue(firstElem[fieldName], gb);
+ }
+ Object.assign(group, this._aggregateFields(measures, records));
+ result.push(group);
+ }
+ if (normalizedGroupBys.length > 0) {
+ const { alias, interval, type } = normalizedGroupBys[0];
+ result = utils.sortBy(result, (group) => {
+ const val = group[alias];
+ if (['date', 'datetime'].includes(type)) {
+ return moment(val, SampleServer.FORMATS[interval]);
+ }
+ return val;
+ });
+ }
+ return result;
+ }
+
+ /**
+ * Mocks calls to the read_progress_bar method.
+ * @private
+ * @param {Object} params
+ * @param {string} params.model
+ * @param {Object} params.kwargs
+ * @return {Object}
+ */
+ _mockReadProgressBar(params) {
+ const groupBy = params.kwargs.group_by;
+ const progress_bar = params.kwargs.progress_bar;
+ const groupByField = this.data[params.model].fields[groupBy];
+ const data = {};
+ for (const record of this.data[params.model].records) {
+ let groupByValue = record[groupBy];
+ if (groupByField.type === "many2one") {
+ const relatedRecords = this.data[groupByField.relation].records;
+ const relatedRecord = relatedRecords.find(r => r.id === groupByValue);
+ groupByValue = relatedRecord.display_name;
+ }
+ if (!(groupByValue in data)) {
+ data[groupByValue] = {};
+ for (const key in progress_bar.colors) {
+ data[groupByValue][key] = 0;
+ }
+ }
+ const fieldValue = record[progress_bar.field];
+ if (fieldValue in data[groupByValue]) {
+ data[groupByValue][fieldValue]++;
+ }
+ }
+ return data;
+ }
+
+ /**
+ * Mocks calls to the /web/dataset/search_read route to return sample
+ * records.
+ * @private
+ * @param {Object} params
+ * @param {string} params.model
+ * @param {string[]} params.fields
+ * @returns {{ records: Object[], length: number }}
+ */
+ _mockSearchReadController(params) {
+ const model = this.data[params.model];
+ const rawRecords = model.records.slice(0, SampleServer.SEARCH_READ_LIMIT);
+ const records = this._mockRead({
+ model: params.model,
+ args: [rawRecords.map(r => r.id), params.fields],
+ });
+ return { records, length: records.length };
+ }
+
+ /**
+ * Mocks calls to the web_read_group method to return groups populated
+ * with sample records. Only handles the case where the real call to
+ * web_read_group returned groups, but none of these groups contain
+ * records. In this case, we keep the real groups, and populate them
+ * with sample records.
+ * @private
+ * @param {Object} params
+ * @param {Object} [result] the result of a real call to web_read_group
+ * @returns {{ groups: Object[], length: number }}
+ */
+ _mockWebReadGroup(params) {
+ let groups;
+ if (this.existingGroups) {
+ this._tweakExistingGroups(params);
+ groups = this.existingGroups;
+ } else {
+ groups = this._mockReadGroup(params);
+ }
+ return {
+ groups,
+ length: groups.length,
+ };
+ }
+
+ /**
+ * Updates the sample data such that the existing groups (in database)
+ * also exists in the sample, and such that there are sample records in
+ * those groups.
+ * @private
+ * @param {Object[]} groups empty groups returned by the server
+ * @param {Object} params
+ * @param {string} params.model
+ * @param {string[]} params.groupBy
+ */
+ _populateExistingGroups(params) {
+ if (!this.existingGroupsPopulated) {
+ const groups = this.existingGroups;
+ this.groupsInfo = groups;
+ const groupBy = params.groupBy[0];
+ const values = groups.map(g => g[groupBy]);
+ const groupByField = this.data[params.model].fields[groupBy];
+ const groupedByM2O = groupByField.type === 'many2one';
+ if (groupedByM2O) { // re-populate co model with relevant records
+ this.data[groupByField.relation].records = values.map(v => {
+ return { id: v[0], display_name: v[1] };
+ });
+ }
+ for (const r of this.data[params.model].records) {
+ const value = getSampleFromId(r.id, values);
+ r[groupBy] = groupedByM2O ? value[0] : value;
+ }
+ this.existingGroupsPopulated = true;
+ }
+ }
+
+ /**
+ * Generates sample records for the models in this.data. Records will be
+ * generated once, and subsequent calls to this function will be skipped.
+ * @private
+ */
+ _populateModels() {
+ if (!this.populated) {
+ for (const modelName in this.data) {
+ const model = this.data[modelName];
+ const fieldNames = Object.keys(model.fields).filter(f => f !== 'id');
+ const size = modelName === this.mainModel ?
+ SampleServer.MAIN_RECORDSET_SIZE :
+ SampleServer.SUB_RECORDSET_SIZE;
+ for (let id = 1; id <= size; id++) {
+ const record = { id };
+ for (const fieldName of fieldNames) {
+ record[fieldName] = this._generateFieldValue(modelName, fieldName, id);
+ }
+ model.records.push(record);
+ }
+ }
+ this.populated = true;
+ }
+ }
+
+ /**
+ * Rounds the given number value according to the configured precision.
+ * @private
+ * @param {number} value
+ * @returns {number}
+ */
+ _sanitizeNumber(value) {
+ return parseFloat(value.toFixed(SampleServer.FLOAT_PRECISION));
+ }
+
+ /**
+ * A real (web_)read_group call has been done, and it has returned groups,
+ * but they are all empty. This function updates the sample data such
+ * that those group values exist and those groups contain sample records.
+ * @private
+ * @param {Object[]} groups empty groups returned by the server
+ * @param {Object} params
+ * @param {string} params.model
+ * @param {string[]} params.fields
+ * @param {string[]} params.groupBy
+ * @returns {Object[]} groups with count and aggregate values updated
+ *
+ * TODO: rename
+ */
+ _tweakExistingGroups(params) {
+ const groups = this.existingGroups;
+ this._populateExistingGroups(params);
+
+ // update count and aggregates for each group
+ const groupBy = params.groupBy[0].split(':')[0];
+ const groupByField = this.data[params.model].fields[groupBy];
+ const groupedByM2O = groupByField.type === 'many2one';
+ const records = this.data[params.model].records;
+ for (const g of groups) {
+ const groupValue = groupedByM2O ? g[groupBy][0] : g[groupBy];
+ const recordsInGroup = records.filter(r => r[groupBy] === groupValue);
+ g[`${groupBy}_count`] = recordsInGroup.length;
+ for (const field of params.fields) {
+ const fieldType = this.data[params.model].fields[field].type;
+ if (['integer, float', 'monetary'].includes(fieldType)) {
+ g[field] = recordsInGroup.reduce((acc, r) => acc + r[field], 0);
+ }
+ }
+ g.__data = {
+ records: this._mockRead({
+ model: params.model,
+ args: [recordsInGroup.map(r => r.id), params.fields],
+ }),
+ length: recordsInGroup.length,
+ };
+ }
+ }
+ }
+
+ SampleServer.FORMATS = {
+ day: 'YYYY-MM-DD',
+ week: '[W]ww YYYY',
+ month: 'MMMM YYYY',
+ quarter: '[Q]Q YYYY',
+ year: 'Y',
+ };
+ SampleServer.DISPLAY_FORMATS = Object.assign({}, SampleServer.FORMATS, { day: 'DD MMM YYYY' });
+
+ SampleServer.MAIN_RECORDSET_SIZE = 16;
+ SampleServer.SUB_RECORDSET_SIZE = 5;
+ SampleServer.SEARCH_READ_LIMIT = 10;
+
+ SampleServer.MAX_FLOAT = 100;
+ SampleServer.MAX_INTEGER = 50;
+ SampleServer.MAX_COLOR_INT = 7;
+ SampleServer.MAX_MONETARY = 100000;
+ SampleServer.DATE_DELTA = 24 * 60; // in hours -> 60 days
+ SampleServer.FLOAT_PRECISION = 2;
+
+ SampleServer.SAMPLE_COUNTRIES = ["Belgium", "France", "Portugal", "Singapore", "Australia"];
+ SampleServer.SAMPLE_PEOPLE = [
+ "John Miller", "Henry Campbell", "Carrie Helle", "Wendi Baltz", "Thomas Passot",
+ ];
+ SampleServer.SAMPLE_TEXTS = [
+ "Laoreet id", "Volutpat blandit", "Integer vitae", "Viverra nam", "In massa",
+ ];
+ SampleServer.PEOPLE_MODELS = [
+ 'res.users', 'res.partner', 'hr.employee', 'mail.followers', 'mailing.contact'
+ ];
+
+ SampleServer.UnimplementedRouteError = UnimplementedRouteError;
+
+ // mockRegistry allows to register mock version of methods or routes,
+ // for all models:
+ // SampleServer.mockRegistry.add('some_route', () => "abcd");
+ // for a specific model (e.g. 'res.partner'):
+ // SampleServer.mockRegistry.add('res.partner/some_method', () => 23);
+ SampleServer.mockRegistry = new Registry();
+
+ return SampleServer;
+});
diff --git a/addons/web/static/src/js/views/search_panel.js b/addons/web/static/src/js/views/search_panel.js
new file mode 100644
index 00000000..71537847
--- /dev/null
+++ b/addons/web/static/src/js/views/search_panel.js
@@ -0,0 +1,214 @@
+odoo.define("web/static/src/js/views/search_panel.js", function (require) {
+ "use strict";
+
+ const { Model, useModel } = require("web/static/src/js/model.js");
+ const patchMixin = require("web.patchMixin");
+
+ const { Component, hooks } = owl;
+ const { useState, useSubEnv } = hooks;
+
+ /**
+ * Search panel
+ *
+ * Represent an extension of the search interface located on the left side of
+ * the view. It is divided in sections defined in a "<searchpanel>" node located
+ * inside of a "<search>" arch. Each section is represented by a list of different
+ * values (categories or ungrouped filters) or groups of values (grouped filters).
+ * Its state is directly affected by its model (@see SearchPanelModelExtension).
+ * @extends Component
+ */
+ class SearchPanel extends Component {
+ constructor() {
+ super(...arguments);
+
+ useSubEnv({ searchModel: this.props.searchModel });
+
+ this.state = useState({
+ active: {},
+ expanded: {},
+ });
+ this.model = useModel("searchModel");
+ this.scrollTop = 0;
+ this.hasImportedState = false;
+
+ this.importState(this.props.importedState);
+ }
+
+ async willStart() {
+ this._expandDefaultValue();
+ this._updateActiveValues();
+ }
+
+ mounted() {
+ this._updateGroupHeadersChecked();
+ if (this.hasImportedState) {
+ this.el.scroll({ top: this.scrollTop });
+ }
+ }
+
+ async willUpdateProps() {
+ this._updateActiveValues();
+ }
+
+ //---------------------------------------------------------------------
+ // Public
+ //---------------------------------------------------------------------
+
+ exportState() {
+ const exported = {
+ expanded: this.state.expanded,
+ scrollTop: this.el.scrollTop,
+ };
+ return JSON.stringify(exported);
+ }
+
+ importState(stringifiedState) {
+ this.hasImportedState = Boolean(stringifiedState);
+ if (this.hasImportedState) {
+ const state = JSON.parse(stringifiedState);
+ this.state.expanded = state.expanded;
+ this.scrollTop = state.scrollTop;
+ }
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * Expands category values holding the default value of a category.
+ * @private
+ */
+ _expandDefaultValue() {
+ if (this.hasImportedState) {
+ return;
+ }
+ const categories = this.model.get("sections", s => s.type === "category");
+ for (const category of categories) {
+ this.state.expanded[category.id] = {};
+ if (category.activeValueId) {
+ const ancestorIds = this._getAncestorValueIds(category, category.activeValueId);
+ for (const ancestorId of ancestorIds) {
+ this.state.expanded[category.id][ancestorId] = true;
+ }
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} category
+ * @param {number} categoryValueId
+ * @returns {number[]} list of ids of the ancestors of the given value in
+ * the given category.
+ */
+ _getAncestorValueIds(category, categoryValueId) {
+ const { parentId } = category.values.get(categoryValueId);
+ return parentId ? [...this._getAncestorValueIds(category, parentId), parentId] : [];
+ }
+
+ /**
+ * Prevent unnecessary calls to the model by ensuring a different category
+ * is clicked.
+ * @private
+ * @param {Object} category
+ * @param {Object} value
+ */
+ async _toggleCategory(category, value) {
+ if (value.childrenIds.length) {
+ const categoryState = this.state.expanded[category.id];
+ if (categoryState[value.id] && category.activeValueId === value.id) {
+ delete categoryState[value.id];
+ } else {
+ categoryState[value.id] = true;
+ }
+ }
+ if (category.activeValueId !== value.id) {
+ this.state.active[category.id] = value.id;
+ this.model.dispatch("toggleCategoryValue", category.id, value.id);
+ }
+ }
+
+ /**
+ * @private
+ * @param {number} filterId
+ * @param {{ values: Map<Object> }} group
+ */
+ _toggleFilterGroup(filterId, { values }) {
+ const valueIds = [];
+ const checked = [...values.values()].every(
+ (value) => this.state.active[filterId][value.id]
+ );
+ values.forEach(({ id }) => {
+ valueIds.push(id);
+ this.state.active[filterId][id] = !checked;
+ });
+ this.model.dispatch("toggleFilterValues", filterId, valueIds, !checked);
+ }
+
+ /**
+ * @private
+ * @param {number} filterId
+ * @param {Object} [group]
+ * @param {number} valueId
+ * @param {MouseEvent} ev
+ */
+ _toggleFilterValue(filterId, valueId, { currentTarget }) {
+ this.state.active[filterId][valueId] = currentTarget.checked;
+ this._updateGroupHeadersChecked();
+ this.model.dispatch("toggleFilterValues", filterId, [valueId]);
+ }
+
+ _updateActiveValues() {
+ for (const section of this.model.get("sections")) {
+ if (section.type === "category") {
+ this.state.active[section.id] = section.activeValueId;
+ } else {
+ this.state.active[section.id] = {};
+ if (section.groups) {
+ for (const group of section.groups.values()) {
+ for (const value of group.values.values()) {
+ this.state.active[section.id][value.id] = value.checked;
+ }
+ }
+ }
+ if (section && section.values) {
+ for (const value of section.values.values()) {
+ this.state.active[section.id][value.id] = value.checked;
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Updates the "checked" or "indeterminate" state of each of the group
+ * headers according to the state of their values.
+ * @private
+ */
+ _updateGroupHeadersChecked() {
+ const groups = this.el.querySelectorAll(":scope .o_search_panel_filter_group");
+ for (const group of groups) {
+ const header = group.querySelector(":scope .o_search_panel_group_header input");
+ const vals = [...group.querySelectorAll(":scope .o_search_panel_filter_value input")];
+ header.checked = false;
+ header.indeterminate = false;
+ if (vals.every((v) => v.checked)) {
+ header.checked = true;
+ } else if (vals.some((v) => v.checked)) {
+ header.indeterminate = true;
+ }
+ }
+ }
+ }
+ SearchPanel.modelExtension = "SearchPanel";
+
+ SearchPanel.props = {
+ className: { type: String, optional: 1 },
+ importedState: { type: String, optional: 1 },
+ searchModel: Model,
+ };
+ SearchPanel.template = "web.SearchPanel";
+
+ return patchMixin(SearchPanel);
+});
diff --git a/addons/web/static/src/js/views/search_panel_model_extension.js b/addons/web/static/src/js/views/search_panel_model_extension.js
new file mode 100644
index 00000000..48466f8d
--- /dev/null
+++ b/addons/web/static/src/js/views/search_panel_model_extension.js
@@ -0,0 +1,789 @@
+odoo.define("web/static/src/js/views/search_panel_model_extension.js", function (require) {
+ "use strict";
+
+ const ActionModel = require("web/static/src/js/views/action_model.js");
+ const { sortBy } = require("web.utils");
+ const Domain = require("web.Domain");
+ const pyUtils = require("web.py_utils");
+
+ // DefaultViewTypes is the list of view types for which the searchpanel is
+ // present by default (if not explicitly stated in the "view_types" attribute
+ // in the arch).
+ const DEFAULT_VIEW_TYPES = ["kanban", "tree"];
+ const DEFAULT_LIMIT = 200;
+ let nextSectionId = 1;
+
+ /**
+ * @param {Filter} filter
+ * @returns {boolean}
+ */
+ function hasDomain(filter) {
+ return filter.domain !== "[]";
+ }
+
+ /**
+ * @param {Section} section
+ * @returns {boolean}
+ */
+ function hasValues({ errorMsg, groups, type, values }) {
+ if (errorMsg) {
+ return true;
+ } else if (groups) {
+ return [...groups.values()].some((g) => g.values.size);
+ } else if (type === "category") {
+ return values && values.size > 1; // false item ignored
+ } else {
+ return values && values.size > 0;
+ }
+ }
+
+ /**
+ * Returns a serialised array of the given map with its values being the
+ * shallow copies of the original values.
+ * @param {Map<any, Object>} map
+ * @return {Array[]}
+ */
+ function serialiseMap(map) {
+ return [...map].map(([key, val]) => [key, Object.assign({}, val)]);
+ }
+
+ /**
+ * @typedef Section
+ * @prop {string} color
+ * @prop {string} description
+ * @prop {boolean} enableCounters
+ * @prop {boolean} expand
+ * @prop {string} fieldName
+ * @prop {string} icon
+ * @prop {number} id
+ * @prop {number} index
+ * @prop {number} limit
+ * @prop {string} type
+ */
+
+ /**
+ * @typedef {Section} Category
+ * @prop {boolean} hierarchize
+ */
+
+ /**
+ * @typedef {Section} Filter
+ * @prop {string} domain
+ * @prop {string} groupBy
+ */
+
+ /**
+ * @function sectionPredicate
+ * @param {Section} section
+ * @returns {boolean}
+ */
+
+ /**
+ * @property {{ sections: Map<number, Section> }} state
+ * @extends ActionModel.Extension
+ */
+ class SearchPanelModelExtension extends ActionModel.Extension {
+ constructor() {
+ super(...arguments);
+
+ this.categoriesToLoad = [];
+ this.defaultValues = {};
+ this.filtersToLoad = [];
+ this.initialStateImport = false;
+ this.searchDomain = [];
+ for (const key in this.config.context) {
+ const match = /^searchpanel_default_(.*)$/.exec(key);
+ if (match) {
+ this.defaultValues[match[1]] = this.config.context[key];
+ }
+ }
+ }
+
+ //---------------------------------------------------------------------
+ // Public
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async callLoad(params) {
+ const searchDomain = this._getExternalDomain();
+ params.searchDomainChanged = (
+ JSON.stringify(this.searchDomain) !== JSON.stringify(searchDomain)
+ );
+ if (!this.shouldLoad && !this.initialStateImport) {
+ const isFetchable = (section) => section.enableCounters ||
+ (params.searchDomainChanged && !section.expand);
+ this.categoriesToLoad = this.categories.filter(isFetchable);
+ this.filtersToLoad = this.filters.filter(isFetchable);
+ this.shouldLoad = params.searchDomainChanged ||
+ Boolean(this.categoriesToLoad.length + this.filtersToLoad.length);
+ }
+ this.searchDomain = searchDomain;
+ this.initialStateImport = false;
+ await super.callLoad(params);
+ }
+
+ /**
+ * @override
+ */
+ exportState() {
+ const state = Object.assign({}, super.exportState());
+ state.sections = serialiseMap(state.sections);
+ for (const [id, section] of state.sections) {
+ section.values = serialiseMap(section.values);
+ if (section.groups) {
+ section.groups = serialiseMap(section.groups);
+ for (const [id, group] of section.groups) {
+ group.values = serialiseMap(group.values);
+ }
+ }
+ }
+ return state;
+ }
+
+ /**
+ * @override
+ * @returns {any}
+ */
+ get(property, ...args) {
+ switch (property) {
+ case "domain": return this.getDomain();
+ case "sections": return this.getSections(...args);
+ }
+ }
+
+ /**
+ * @override
+ */
+ importState(importedState) {
+ this.initialStateImport = Boolean(importedState && !this.state.sections);
+ super.importState(...arguments);
+ if (importedState) {
+ this.state.sections = new Map(this.state.sections);
+ for (const section of this.state.sections.values()) {
+ section.values = new Map(section.values);
+ if (section.groups) {
+ section.groups = new Map(section.groups);
+ for (const group of section.groups.values()) {
+ group.values = new Map(group.values);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * @override
+ */
+ async isReady() {
+ await this.sectionsPromise;
+ }
+
+ /**
+ * @override
+ */
+ async load(params) {
+ this.sectionsPromise = this._fetchSections(params.isInitialLoad);
+ if (this._shouldWaitForData(params)) {
+ await this.sectionsPromise;
+ }
+ }
+
+ /**
+ * @override
+ */
+ prepareState() {
+ Object.assign(this.state, { sections: new Map() });
+ this._createSectionsFromArch();
+ }
+
+ //---------------------------------------------------------------------
+ // Actions / Getters
+ //---------------------------------------------------------------------
+
+ /**
+ * Returns the concatenation of the category domain ad the filter
+ * domain.
+ * @returns {Array[]}
+ */
+ getDomain() {
+ return Domain.prototype.normalizeArray([
+ ...this._getCategoryDomain(),
+ ...this._getFilterDomain(),
+ ]);
+ }
+
+ /**
+ * Returns a sorted list of a copy of all sections. This list can be
+ * filtered by a given predicate.
+ * @param {sectionPredicate} [predicate] used to determine
+ * which subsets of sections is wanted
+ * @returns {Section[]}
+ */
+ getSections(predicate) {
+ let sections = [...this.state.sections.values()].map((section) =>
+ Object.assign({}, section, { empty: !hasValues(section) })
+ );
+ if (predicate) {
+ sections = sections.filter(predicate);
+ }
+ return sections.sort((s1, s2) => s1.index - s2.index);
+ }
+
+ /**
+ * Sets the active value id of a given category.
+ * @param {number} sectionId
+ * @param {number} valueId
+ */
+ toggleCategoryValue(sectionId, valueId) {
+ const category = this.state.sections.get(sectionId);
+ category.activeValueId = valueId;
+ }
+
+ /**
+ * Toggles a the filter value of a given section. The value will be set
+ * to "forceTo" if provided, else it will be its own opposed value.
+ * @param {number} sectionId
+ * @param {number[]} valueIds
+ * @param {boolean} [forceTo=null]
+ */
+ toggleFilterValues(sectionId, valueIds, forceTo = null) {
+ const section = this.state.sections.get(sectionId);
+ for (const valueId of valueIds) {
+ const value = section.values.get(valueId);
+ value.checked = forceTo === null ? !value.checked : forceTo;
+ }
+ }
+
+ //---------------------------------------------------------------------
+ // Internal getters
+ //---------------------------------------------------------------------
+
+ /**
+ * Shorthand access to sections of type "category".
+ * @returns {Category[]}
+ */
+ get categories() {
+ return [...this.state.sections.values()].filter(s => s.type === "category");
+ }
+
+ /**
+ * Shorthand access to sections of type "filter".
+ * @returns {Filter[]}
+ */
+ get filters() {
+ return [...this.state.sections.values()].filter(s => s.type === "filter");
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * Sets active values for each filter (coming from context). This needs
+ * to be done only once at initialization.
+ * @private
+ */
+ _applyDefaultFilterValues() {
+ for (const { fieldName, values } of this.filters) {
+ const defaultValues = this.defaultValues[fieldName] || [];
+ for (const valueId of defaultValues) {
+ const value = values.get(valueId);
+ if (value) {
+ value.checked = true;
+ }
+ }
+ }
+ }
+
+ /**
+ * @private
+ * @param {string} sectionId
+ * @param {Object} result
+ */
+ _createCategoryTree(sectionId, result) {
+ const category = this.state.sections.get(sectionId);
+
+ let { error_msg, parent_field: parentField, values, } = result;
+ if (error_msg) {
+ category.errorMsg = error_msg;
+ values = [];
+ }
+ if (category.hierarchize) {
+ category.parentField = parentField;
+ }
+ for (const value of values) {
+ category.values.set(
+ value.id,
+ Object.assign({}, value, {
+ childrenIds: [],
+ parentId: value[parentField] || false,
+ })
+ );
+ }
+ for (const value of values) {
+ const { parentId } = category.values.get(value.id);
+ if (parentId && category.values.has(parentId)) {
+ category.values.get(parentId).childrenIds.push(value.id);
+ }
+ }
+ // collect rootIds
+ category.rootIds = [false];
+ for (const value of values) {
+ const { parentId } = category.values.get(value.id);
+ if (!parentId) {
+ category.rootIds.push(value.id);
+ }
+ }
+ // Set active value from context
+ const valueIds = [false, ...values.map((val) => val.id)];
+ this._ensureCategoryValue(category, valueIds);
+ }
+
+ /**
+ * @private
+ * @param {string} sectionId
+ * @param {Object} result
+ */
+ _createFilterTree(sectionId, result) {
+ const filter = this.state.sections.get(sectionId);
+
+ let { error_msg, values, } = result;
+ if (error_msg) {
+ filter.errorMsg = error_msg;
+ values = [];
+ }
+
+ // restore checked property
+ values.forEach((value) => {
+ const oldValue = filter.values.get(value.id);
+ value.checked = oldValue ? oldValue.checked : false;
+ });
+
+ filter.values = new Map();
+ const groupIds = [];
+ if (filter.groupBy) {
+ const groups = new Map();
+ for (const value of values) {
+ const groupId = value.group_id;
+ if (!groups.has(groupId)) {
+ if (groupId) {
+ groupIds.push(groupId);
+ }
+ groups.set(groupId, {
+ id: groupId,
+ name: value.group_name,
+ values: new Map(),
+ tooltip: value.group_tooltip,
+ sequence: value.group_sequence,
+ hex_color: value.group_hex_color,
+ });
+ // restore former checked state
+ const oldGroup =
+ filter.groups && filter.groups.get(groupId);
+ groups.get(groupId).state =
+ (oldGroup && oldGroup.state) || false;
+ }
+ groups.get(groupId).values.set(value.id, value);
+ }
+ filter.groups = groups;
+ filter.sortedGroupIds = sortBy(
+ groupIds,
+ (id) => groups.get(id).sequence || groups.get(id).name
+ );
+ for (const group of filter.groups.values()) {
+ for (const [valueId, value] of group.values) {
+ filter.values.set(valueId, value);
+ }
+ }
+ } else {
+ for (const value of values) {
+ filter.values.set(value.id, value);
+ }
+ }
+ }
+
+ /**
+ * Adds a section in this.state.sections for each visible field found
+ * in the search panel arch.
+ * @private
+ */
+ _createSectionsFromArch() {
+ let hasCategoryWithCounters = false;
+ let hasFilterWithDomain = false;
+ this.config.archNodes.forEach(({ attrs, tag }, index) => {
+ if (tag !== "field" || attrs.invisible === "1") {
+ return;
+ }
+ const type = attrs.select === "multi" ? "filter" : "category";
+ const section = {
+ color: attrs.color,
+ description:
+ attrs.string || this.config.fields[attrs.name].string,
+ enableCounters: !!pyUtils.py_eval(
+ attrs.enable_counters || "0"
+ ),
+ expand: !!pyUtils.py_eval(attrs.expand || "0"),
+ fieldName: attrs.name,
+ icon: attrs.icon,
+ id: nextSectionId++,
+ index,
+ limit: pyUtils.py_eval(attrs.limit || String(DEFAULT_LIMIT)),
+ type,
+ values: new Map(),
+ };
+ if (type === "category") {
+ section.activeValueId = this.defaultValues[attrs.name];
+ section.icon = section.icon || "fa-folder";
+ section.hierarchize = !!pyUtils.py_eval(
+ attrs.hierarchize || "1"
+ );
+ section.values.set(false, {
+ childrenIds: [],
+ display_name: this.env._t("All"),
+ id: false,
+ bold: true,
+ parentId: false,
+ });
+ hasCategoryWithCounters = hasCategoryWithCounters || section.enableCounters;
+ } else {
+ section.domain = attrs.domain || "[]";
+ section.groupBy = attrs.groupby;
+ section.icon = section.icon || "fa-filter";
+ hasFilterWithDomain = hasFilterWithDomain || section.domain !== "[]";
+ }
+ this.state.sections.set(section.id, section);
+ });
+ /**
+ * Category counters are automatically disabled if a filter domain is found
+ * to avoid inconsistencies with the counters. The underlying problem could
+ * actually be solved by reworking the search panel and the way the
+ * counters are computed, though this is not the current priority
+ * considering the time it would take, hence this quick "fix".
+ */
+ if (hasCategoryWithCounters && hasFilterWithDomain) {
+ // If incompatibilities are found -> disables all category counters
+ for (const category of this.categories) {
+ category.enableCounters = false;
+ }
+ // ... and triggers a warning
+ console.warn(
+ "Warning: categories with counters are incompatible with filters having a domain attribute.",
+ "All category counters have been disabled to avoid inconsistencies.",
+ );
+ }
+ }
+
+ /**
+ * Ensures that the active value of a category is one of its own
+ * existing values.
+ * @private
+ * @param {Category} category
+ * @param {number[]} valueIds
+ */
+ _ensureCategoryValue(category, valueIds) {
+ if (!valueIds.includes(category.activeValueId)) {
+ category.activeValueId = valueIds[0];
+ }
+ }
+
+ /**
+ * Fetches values for each category at startup. At reload a category is
+ * only fetched if needed.
+ * @private
+ * @param {Category[]} categories
+ * @returns {Promise} resolved when all categories have been fetched
+ */
+ async _fetchCategories(categories) {
+ const filterDomain = this._getFilterDomain();
+ await Promise.all(categories.map(async (category) => {
+ const result = await this.env.services.rpc({
+ method: "search_panel_select_range",
+ model: this.config.modelName,
+ args: [category.fieldName],
+ kwargs: {
+ category_domain: this._getCategoryDomain(category.id),
+ enable_counters: category.enableCounters,
+ expand: category.expand,
+ filter_domain: filterDomain,
+ hierarchize: category.hierarchize,
+ limit: category.limit,
+ search_domain: this.searchDomain,
+ },
+ });
+ this._createCategoryTree(category.id, result);
+ }));
+ }
+
+ /**
+ * Fetches values for each filter. This is done at startup and at each
+ * reload if needed.
+ * @private
+ * @param {Filter[]} filters
+ * @returns {Promise} resolved when all filters have been fetched
+ */
+ async _fetchFilters(filters) {
+ const evalContext = {};
+ for (const category of this.categories) {
+ evalContext[category.fieldName] = category.activeValueId;
+ }
+ const categoryDomain = this._getCategoryDomain();
+ await Promise.all(filters.map(async (filter) => {
+ const result = await this.env.services.rpc({
+ method: "search_panel_select_multi_range",
+ model: this.config.modelName,
+ args: [filter.fieldName],
+ kwargs: {
+ category_domain: categoryDomain,
+ comodel_domain: Domain.prototype.stringToArray(
+ filter.domain,
+ evalContext
+ ),
+ enable_counters: filter.enableCounters,
+ filter_domain: this._getFilterDomain(filter.id),
+ expand: filter.expand,
+ group_by: filter.groupBy || false,
+ group_domain: this._getGroupDomain(filter),
+ limit: filter.limit,
+ search_domain: this.searchDomain,
+ },
+ });
+ this._createFilterTree(filter.id, result);
+ }));
+ }
+
+ /**
+ * @private
+ * @param {boolean} isInitialLoad
+ * @returns {Promise}
+ */
+ async _fetchSections(isInitialLoad) {
+ await this._fetchCategories(
+ isInitialLoad ? this.categories : this.categoriesToLoad
+ );
+ await this._fetchFilters(
+ isInitialLoad ? this.filters : this.filtersToLoad
+ );
+ if (isInitialLoad) {
+ this._applyDefaultFilterValues();
+ }
+ }
+
+ /**
+ * Computes and returns the domain based on the current active
+ * categories. If "excludedCategoryId" is provided, the category with
+ * that id is not taken into account in the domain computation.
+ * @private
+ * @param {string} [excludedCategoryId]
+ * @returns {Array[]}
+ */
+ _getCategoryDomain(excludedCategoryId) {
+ const domain = [];
+ for (const category of this.categories) {
+ if (
+ category.id === excludedCategoryId ||
+ !category.activeValueId
+ ) {
+ continue;
+ }
+ const field = this.config.fields[category.fieldName];
+ const operator =
+ field.type === "many2one" && category.parentField ? "child_of" : "=";
+ domain.push([
+ category.fieldName,
+ operator,
+ category.activeValueId,
+ ]);
+ }
+ return domain;
+ }
+
+ /**
+ * Returns the domain retrieved from the other model extensions.
+ * @private
+ * @returns {Array[]}
+ */
+ _getExternalDomain() {
+ const domains = this.config.get("domain");
+ const domain = domains.reduce((acc, dom) => [...acc, ...dom], []);
+ return Domain.prototype.normalizeArray(domain);
+ }
+
+ /**
+ * Computes and returns the domain based on the current checked
+ * filters. The values of a single filter are combined using a simple
+ * rule: checked values within a same group are combined with an "OR"
+ * operator (this is expressed as single condition using a list) and
+ * groups are combined with an "AND" operator (expressed by
+ * concatenation of conditions).
+ * If a filter has no group, its checked values are implicitely
+ * considered as forming a group (and grouped using an "OR").
+ * If excludedFilterId is provided, the filter with that id is not
+ * taken into account in the domain computation.
+ * @private
+ * @param {string} [excludedFilterId]
+ * @returns {Array[]}
+ */
+ _getFilterDomain(excludedFilterId) {
+ const domain = [];
+
+ function addCondition(fieldName, valueMap) {
+ const ids = [];
+ for (const [valueId, value] of valueMap) {
+ if (value.checked) {
+ ids.push(valueId);
+ }
+ }
+ if (ids.length) {
+ domain.push([fieldName, "in", ids]);
+ }
+ }
+
+ for (const filter of this.filters) {
+ if (filter.id === excludedFilterId) {
+ continue;
+ }
+ const { fieldName, groups, values } = filter;
+ if (groups) {
+ for (const group of groups.values()) {
+ addCondition(fieldName, group.values);
+ }
+ } else {
+ addCondition(fieldName, values);
+ }
+ }
+ return domain;
+ }
+
+ /**
+ * Returns a domain or an object of domains used to complement
+ * the filter domains to accurately describe the constrains on
+ * records when computing record counts associated to the filter
+ * values (if a groupBy is provided). The idea is that the checked
+ * values within a group should not impact the counts for the other
+ * values in the same group.
+ * @private
+ * @param {string} filter
+ * @returns {Object<string, Array[]> | Array[] | null}
+ */
+ _getGroupDomain(filter) {
+ const { fieldName, groups, enableCounters } = filter;
+ const { type: fieldType } = this.config.fields[fieldName];
+
+ if (!enableCounters || !groups) {
+ return {
+ many2one: [],
+ many2many: {},
+ }[fieldType];
+ }
+ let groupDomain = null;
+ if (fieldType === "many2one") {
+ for (const group of groups.values()) {
+ const valueIds = [];
+ let active = false;
+ for (const [valueId, value] of group.values) {
+ const { checked } = value;
+ valueIds.push(valueId);
+ if (checked) {
+ active = true;
+ }
+ }
+ if (active) {
+ if (groupDomain) {
+ groupDomain = [[0, "=", 1]];
+ break;
+ } else {
+ groupDomain = [[fieldName, "in", valueIds]];
+ }
+ }
+ }
+ } else if (fieldType === "many2many") {
+ const checkedValueIds = new Map();
+ groups.forEach(({ values }, groupId) => {
+ values.forEach(({ checked }, valueId) => {
+ if (checked) {
+ if (!checkedValueIds.has(groupId)) {
+ checkedValueIds.set(groupId, []);
+ }
+ checkedValueIds.get(groupId).push(valueId);
+ }
+ });
+ });
+ groupDomain = {};
+ for (const [gId, ids] of checkedValueIds.entries()) {
+ for (const groupId of groups.keys()) {
+ if (gId !== groupId) {
+ const key = JSON.stringify(groupId);
+ if (!groupDomain[key]) {
+ groupDomain[key] = [];
+ }
+ groupDomain[key].push([fieldName, "in", ids]);
+ }
+ }
+ }
+ }
+ return groupDomain;
+ }
+
+ /**
+ * Returns whether the query informations should be considered as ready
+ * before or after having (re-)fetched the sections data.
+ * @private
+ * @param {Object} params
+ * @param {boolean} params.isInitialLoad
+ * @param {boolean} params.searchDomainChanged
+ * @returns {boolean}
+ */
+ _shouldWaitForData({ isInitialLoad, searchDomainChanged }) {
+ if (isInitialLoad && Object.keys(this.defaultValues).length) {
+ // Default values need to be checked on initial load
+ return true;
+ }
+ if (this.categories.length && this.filters.some(hasDomain)) {
+ // Selected category value might affect the filter values
+ return true;
+ }
+ if (!this.searchDomain.length) {
+ // No search domain -> no need to check for expand
+ return false;
+ }
+ return [...this.state.sections.values()].some(
+ (section) => !section.expand && searchDomainChanged
+ );
+ }
+
+ //---------------------------------------------------------------------
+ // Static
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {{ attrs: Object, children: Object[] } | null}
+ */
+ static extractArchInfo(archs, viewType) {
+ const { children } = archs.search;
+ const spNode = children.find(c => c.tag === "searchpanel");
+ const isObject = (obj) => typeof obj === "object" && obj !== null;
+ if (spNode) {
+ const actualType = viewType === "list" ? "tree" : viewType;
+ const { view_types } = spNode.attrs;
+ const viewTypes = view_types ?
+ view_types.split(",") :
+ DEFAULT_VIEW_TYPES;
+ if (viewTypes.includes(actualType)) {
+ return {
+ attrs: spNode.attrs,
+ children: spNode.children.filter(isObject),
+ };
+ }
+ }
+ return null;
+ }
+ }
+ SearchPanelModelExtension.layer = 1;
+
+ ActionModel.registry.add("SearchPanel", SearchPanelModelExtension, 30);
+
+ return SearchPanelModelExtension;
+});
diff --git a/addons/web/static/src/js/views/select_create_controllers_registry.js b/addons/web/static/src/js/views/select_create_controllers_registry.js
new file mode 100644
index 00000000..2cbc0fd0
--- /dev/null
+++ b/addons/web/static/src/js/views/select_create_controllers_registry.js
@@ -0,0 +1,60 @@
+odoo.define('web.select_create_controllers_registry', function (require) {
+"use strict";
+
+return {};
+
+});
+
+odoo.define('web._select_create_controllers_registry', function (require) {
+"use strict";
+
+var KanbanController = require('web.KanbanController');
+var ListController = require('web.ListController');
+var select_create_controllers_registry = require('web.select_create_controllers_registry');
+
+var SelectCreateKanbanController = KanbanController.extend({
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to select the clicked record instead of opening it
+ *
+ * @override
+ * @private
+ */
+ _onOpenRecord: function (ev) {
+ var selectedRecord = this.model.get(ev.data.id);
+ this.trigger_up('select_record', {
+ id: selectedRecord.res_id,
+ display_name: selectedRecord.data.display_name,
+ });
+ },
+});
+
+var SelectCreateListController = ListController.extend({
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to select the clicked record instead of opening it
+ *
+ * @override
+ * @private
+ */
+ _onOpenRecord: function (ev) {
+ var selectedRecord = this.model.get(ev.data.id);
+ this.trigger_up('select_record', {
+ id: selectedRecord.res_id,
+ display_name: selectedRecord.data.display_name,
+ });
+ },
+});
+
+_.extend(select_create_controllers_registry, {
+ SelectCreateListController: SelectCreateListController,
+ SelectCreateKanbanController: SelectCreateKanbanController,
+});
+
+});
diff --git a/addons/web/static/src/js/views/signature_dialog.js b/addons/web/static/src/js/views/signature_dialog.js
new file mode 100644
index 00000000..12bb18f4
--- /dev/null
+++ b/addons/web/static/src/js/views/signature_dialog.js
@@ -0,0 +1,121 @@
+odoo.define('web.signature_dialog', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var NameAndSignature = require('web.name_and_signature').NameAndSignature;
+
+var _t = core._t;
+
+// The goal of this dialog is to ask the user a signature request.
+// It uses @see SignNameAndSignature for the name and signature fields.
+var SignatureDialog = Dialog.extend({
+ template: 'web.signature_dialog',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/web/static/src/xml/name_and_signature.xml']
+ ),
+ custom_events: {
+ 'signature_changed': '_onChangeSignature',
+ },
+
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} options
+ * @param {string} [options.title='Adopt Your Signature'] - modal title
+ * @param {string} [options.size='medium'] - modal size
+ * @param {Object} [options.nameAndSignatureOptions={}] - options for
+ * @see NameAndSignature.init()
+ */
+ init: function (parent, options) {
+ var self = this;
+ options = options || {};
+
+ options.title = options.title || _t("Adopt Your Signature");
+ options.size = options.size || 'medium';
+ options.technical = false;
+
+ if (!options.buttons) {
+ options.buttons = [];
+ options.buttons.push({text: _t("Adopt and Sign"), classes: "btn-primary", disabled: true, click: function (e) {
+ self._onConfirm();
+ }});
+ options.buttons.push({text: _t("Cancel"), close: true});
+ }
+
+ this._super(parent, options);
+
+ this.nameAndSignature = new NameAndSignature(this, options.nameAndSignatureOptions);
+ },
+ /**
+ * Start the nameAndSignature widget and wait for it.
+ *
+ * @override
+ */
+ willStart: function () {
+ return Promise.all([
+ this.nameAndSignature.appendTo($('<div>')),
+ this._super.apply(this, arguments)
+ ]);
+ },
+ /**
+ * Initialize the name and signature widget when the modal is opened.
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.$primaryButton = this.$footer.find('.btn-primary');
+
+ this.opened().then(function () {
+ self.$('.o_web_sign_name_and_signature').replaceWith(self.nameAndSignature.$el);
+ // initialize the signature area
+ self.nameAndSignature.resetSignature();
+ });
+
+ return this._super.apply(this, arguments);
+ },
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Returns whether the drawing area is currently empty.
+ *
+ * @see NameAndSignature.isSignatureEmpty()
+ * @returns {boolean} Whether the drawing area is currently empty.
+ */
+ isSignatureEmpty: function () {
+ return this.nameAndSignature.isSignatureEmpty();
+ },
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * Toggles the submit button depending on the signature state.
+ *
+ * @private
+ */
+ _onChangeSignature: function () {
+ var isEmpty = this.nameAndSignature.isSignatureEmpty();
+ this.$primaryButton.prop('disabled', isEmpty);
+ },
+ /**
+ * Upload the signature image when confirm.
+ *
+ * @private
+ */
+ _onConfirm: function (fct) {
+ this.trigger_up('upload_signature', {
+ name: this.nameAndSignature.getName(),
+ signatureImage: this.nameAndSignature.getSignatureImage(),
+ });
+ },
+});
+
+return SignatureDialog;
+
+});
diff --git a/addons/web/static/src/js/views/standalone_field_manager_mixin.js b/addons/web/static/src/js/views/standalone_field_manager_mixin.js
new file mode 100644
index 00000000..501ecf7c
--- /dev/null
+++ b/addons/web/static/src/js/views/standalone_field_manager_mixin.js
@@ -0,0 +1,64 @@
+odoo.define('web.StandaloneFieldManagerMixin', function (require) {
+"use strict";
+
+
+var FieldManagerMixin = require('web.FieldManagerMixin');
+
+/**
+ * The StandaloneFieldManagerMixin is a mixin, designed to be used by a widget
+ * that instanciates its own field widgets.
+ *
+ * @mixin
+ * @name StandaloneFieldManagerMixin
+ * @mixes FieldManagerMixin
+ * @property {Function} _confirmChange
+ * @property {Function} _registerWidget
+ */
+var StandaloneFieldManagerMixin = _.extend({}, FieldManagerMixin, {
+
+ /**
+ * @override
+ */
+ init: function () {
+ FieldManagerMixin.init.apply(this, arguments);
+
+ // registeredWidgets is a dict of all field widgets used by the widget
+ this.registeredWidgets = {};
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method will be called whenever a field value has changed (and has
+ * been confirmed by the model).
+ *
+ * @private
+ * @param {string} id basicModel Id for the changed record
+ * @param {string[]} fields the fields (names) that have been changed
+ * @param {OdooEvent} event the event that triggered the change
+ * @returns {Promise}
+ */
+ _confirmChange: function (id, fields, event) {
+ var result = FieldManagerMixin._confirmChange.apply(this, arguments);
+ var record = this.model.get(id);
+ _.each(this.registeredWidgets[id], function (widget, fieldName) {
+ if (_.contains(fields, fieldName)) {
+ widget.reset(record, event);
+ }
+ });
+ return result;
+ },
+
+ _registerWidget: function (datapointID, fieldName, widget) {
+ if (!this.registeredWidgets[datapointID]) {
+ this.registeredWidgets[datapointID] = {};
+ }
+ this.registeredWidgets[datapointID][fieldName] = widget;
+ },
+});
+
+return StandaloneFieldManagerMixin;
+
+});
diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js
new file mode 100644
index 00000000..21004ed9
--- /dev/null
+++ b/addons/web/static/src/js/views/view_dialogs.js
@@ -0,0 +1,484 @@
+odoo.define('web.view_dialogs', function (require) {
+"use strict";
+
+var config = require('web.config');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var dom = require('web.dom');
+var view_registry = require('web.view_registry');
+var select_create_controllers_registry = require('web.select_create_controllers_registry');
+
+var _t = core._t;
+
+/**
+ * Class with everything which is common between FormViewDialog and
+ * SelectCreateDialog.
+ */
+var ViewDialog = Dialog.extend({
+ custom_events: _.extend({}, Dialog.prototype.custom_events, {
+ push_state: '_onPushState',
+ }),
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {options} [options]
+ * @param {string} [options.dialogClass=o_act_window]
+ * @param {string} [options.res_model] the model of the record(s) to open
+ * @param {any[]} [options.domain]
+ * @param {Object} [options.context]
+ */
+ init: function (parent, options) {
+ options = options || {};
+ options.fullscreen = config.device.isMobile;
+ options.dialogClass = options.dialogClass || '' + ' o_act_window';
+
+ this._super(parent, $.extend(true, {}, options));
+
+ this.res_model = options.res_model || null;
+ this.domain = options.domain || [];
+ this.context = options.context || {};
+ this.options = _.extend(this.options || {}, options || {});
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * We stop all push_state events from bubbling up. It would be weird to
+ * change the url because a dialog opened.
+ *
+ * @param {OdooEvent} event
+ */
+ _onPushState: function (event) {
+ event.stopPropagation();
+ },
+});
+
+/**
+ * Create and edit dialog (displays a form view record and leave once saved)
+ */
+var FormViewDialog = ViewDialog.extend({
+ /**
+ * @param {Widget} parent
+ * @param {Object} [options]
+ * @param {string} [options.parentID] the id of the parent record. It is
+ * useful for situations such as a one2many opened in a form view dialog.
+ * In that case, we want to be able to properly evaluate domains with the
+ * 'parent' key.
+ * @param {integer} [options.res_id] the id of the record to open
+ * @param {Object} [options.form_view_options] dict of options to pass to
+ * the Form View @todo: make it work
+ * @param {Object} [options.fields_view] optional form fields_view
+ * @param {boolean} [options.readonly=false] only applicable when not in
+ * creation mode
+ * @param {boolean} [options.deletable=false] whether or not the record can
+ * be deleted
+ * @param {boolean} [options.disable_multiple_selection=false] set to true
+ * to remove the possibility to create several records in a row
+ * @param {function} [options.on_saved] callback executed after saving a
+ * record. It will be called with the record data, and a boolean which
+ * indicates if something was changed
+ * @param {function} [options.on_remove] callback executed when the user
+ * clicks on the 'Remove' button
+ * @param {BasicModel} [options.model] if given, it will be used instead of
+ * a new form view model
+ * @param {string} [options.recordID] if given, the model has to be given as
+ * well, and in that case, it will be used without loading anything.
+ * @param {boolean} [options.shouldSaveLocally] if true, the view dialog
+ * will save locally instead of actually saving (useful for one2manys)
+ * @param {function} [options._createContext] function to get context for name field
+ * useful for many2many_tags widget where we want to removed default_name field
+ * context.
+ */
+ init: function (parent, options) {
+ var self = this;
+ options = options || {};
+
+ this.res_id = options.res_id || null;
+ this.on_saved = options.on_saved || (function () {});
+ this.on_remove = options.on_remove || (function () {});
+ this.context = options.context;
+ this._createContext = options._createContext;
+ this.model = options.model;
+ this.parentID = options.parentID;
+ this.recordID = options.recordID;
+ this.shouldSaveLocally = options.shouldSaveLocally;
+ this.readonly = options.readonly;
+ this.deletable = options.deletable;
+ this.disable_multiple_selection = options.disable_multiple_selection;
+ var oBtnRemove = 'o_btn_remove';
+
+ var multi_select = !_.isNumber(options.res_id) && !options.disable_multiple_selection;
+ var readonly = _.isNumber(options.res_id) && options.readonly;
+
+ if (!options.buttons) {
+ options.buttons = [{
+ text: options.close_text || (readonly ? _t("Close") : _t("Discard")),
+ classes: "btn-secondary o_form_button_cancel",
+ close: true,
+ click: function () {
+ if (!readonly) {
+ self.form_view.model.discardChanges(self.form_view.handle, {
+ rollback: self.shouldSaveLocally,
+ });
+ }
+ },
+ }];
+
+ if (!readonly) {
+ options.buttons.unshift({
+ text: options.save_text || (multi_select ? _t("Save & Close") : _t("Save")),
+ classes: "btn-primary",
+ click: function () {
+ self._save().then(self.close.bind(self));
+ }
+ });
+
+ if (multi_select) {
+ options.buttons.splice(1, 0, {
+ text: _t("Save & New"),
+ classes: "btn-primary",
+ click: function () {
+ self._save()
+ .then(function () {
+ // reset default name field from context when Save & New is clicked, pass additional
+ // context so that when getContext is called additional context resets it
+ var additionalContext = self._createContext && self._createContext(false) || {};
+ self.form_view.createRecord(self.parentID, additionalContext);
+ })
+ .then(function () {
+ if (!self.deletable) {
+ return;
+ }
+ self.deletable = false;
+ self.buttons = self.buttons.filter(function (button) {
+ return button.classes.split(' ').indexOf(oBtnRemove) < 0;
+ });
+ self.set_buttons(self.buttons);
+ self.set_title(_t("Create ") + _.str.strRight(self.title, _t("Open: ")));
+ });
+ },
+ });
+ }
+
+ var multi = options.disable_multiple_selection;
+ if (!multi && this.deletable) {
+ this._setRemoveButtonOption(options, oBtnRemove);
+ }
+ }
+ }
+ this._super(parent, options);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Open the form view dialog. It is necessarily asynchronous, but this
+ * method returns immediately.
+ *
+ * @returns {FormViewDialog} this instance
+ */
+ open: function () {
+ var self = this;
+ var _super = this._super.bind(this);
+ var FormView = view_registry.get('form');
+ var fields_view_def;
+ if (this.options.fields_view) {
+ fields_view_def = Promise.resolve(this.options.fields_view);
+ } else {
+ fields_view_def = this.loadFieldView(this.res_model, this.context, this.options.view_id, 'form');
+ }
+
+ fields_view_def.then(function (viewInfo) {
+ var refinedContext = _.pick(self.context, function (value, key) {
+ return key.indexOf('_view_ref') === -1;
+ });
+ var formview = new FormView(viewInfo, {
+ modelName: self.res_model,
+ context: refinedContext,
+ ids: self.res_id ? [self.res_id] : [],
+ currentId: self.res_id || undefined,
+ index: 0,
+ mode: self.res_id && self.options.readonly ? 'readonly' : 'edit',
+ footerToButtons: true,
+ default_buttons: false,
+ withControlPanel: false,
+ model: self.model,
+ parentID: self.parentID,
+ recordID: self.recordID,
+ isFromFormViewDialog: true,
+ });
+ return formview.getController(self);
+ }).then(function (formView) {
+ self.form_view = formView;
+ var fragment = document.createDocumentFragment();
+ if (self.recordID && self.shouldSaveLocally) {
+ self.model.save(self.recordID, {savePoint: true});
+ }
+ return self.form_view.appendTo(fragment)
+ .then(function () {
+ self.opened().then(function () {
+ var $buttons = $('<div>');
+ self.form_view.renderButtons($buttons);
+ if ($buttons.children().length) {
+ self.$footer.empty().append($buttons.contents());
+ }
+ dom.append(self.$el, fragment, {
+ callbacks: [{widget: self.form_view}],
+ in_DOM: true,
+ });
+ self.form_view.updateButtons();
+ });
+ return _super();
+ });
+ });
+
+ return this;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _focusOnClose: function() {
+ var isFocusSet = false;
+ this.trigger_up('form_dialog_discarded', {
+ callback: function (isFocused) {
+ isFocusSet = isFocused;
+ },
+ });
+ return isFocusSet;
+ },
+
+ /**
+ * @private
+ */
+ _remove: function () {
+ return Promise.resolve(this.on_remove());
+ },
+
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _save: function () {
+ var self = this;
+ return this.form_view.saveRecord(this.form_view.handle, {
+ stayInEdit: true,
+ reload: false,
+ savePoint: this.shouldSaveLocally,
+ viewType: 'form',
+ }).then(function (changedFields) {
+ // record might have been changed by the save (e.g. if this was a new record, it has an
+ // id now), so don't re-use the copy obtained before the save
+ var record = self.form_view.model.get(self.form_view.handle);
+ return self.on_saved(record, !!changedFields.length);
+ });
+ },
+
+ /**
+ * Set the "remove" button into the options' buttons list
+ *
+ * @private
+ * @param {Object} options The options object to modify
+ * @param {string} btnClasses The classes for the remove button
+ */
+ _setRemoveButtonOption(options, btnClasses) {
+ const self = this;
+ options.buttons.push({
+ text: _t("Remove"),
+ classes: 'btn-secondary ' + btnClasses,
+ click: function() {
+ self._remove().then(self.close.bind(self));
+ }
+ });
+ },
+});
+
+/**
+ * Search dialog (displays a list of records and permits to create a new one by switching to a form view)
+ */
+var SelectCreateDialog = ViewDialog.extend({
+ custom_events: _.extend({}, ViewDialog.prototype.custom_events, {
+ select_record: function (event) {
+ if (!this.options.readonly) {
+ this.on_selected([event.data]);
+ this.close();
+ }
+ },
+ selection_changed: function (event) {
+ event.stopPropagation();
+ this.$footer.find(".o_select_button").prop('disabled', !event.data.selection.length);
+ },
+ }),
+
+ /**
+ * options:
+ * - initial_ids
+ * - initial_view: form or search (default search)
+ * - list_view_options: dict of options to pass to the List View
+ * - on_selected: optional callback to execute when records are selected
+ * - disable_multiple_selection: true to allow create/select multiple records
+ * - dynamicFilters: filters to add to the searchview
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ _.defaults(this.options, { initial_view: 'search' });
+ this.on_selected = this.options.on_selected || (function () {});
+ this.on_closed = this.options.on_closed || (function () {});
+ this.initialIDs = this.options.initial_ids;
+ this.viewType = 'list';
+ },
+
+ open: function () {
+ if (this.options.initial_view !== "search") {
+ return this.create_edit_record();
+ }
+ var self = this;
+ var _super = this._super.bind(this);
+ var viewRefID = this.viewType === 'kanban' ?
+ (this.options.kanban_view_ref && JSON.parse(this.options.kanban_view_ref) || false) : false;
+ return this.loadViews(this.res_model, this.context, [[viewRefID, this.viewType], [false, 'search']], {load_filters: true})
+ .then(this.setup.bind(this))
+ .then(function (fragment) {
+ self.opened().then(function () {
+ dom.append(self.$el, fragment, {
+ callbacks: [{widget: self.viewController}],
+ in_DOM: true,
+ });
+ self.set_buttons(self.__buttons);
+ });
+ return _super();
+ });
+ },
+
+ setup: function (fieldsViews) {
+ var self = this;
+ var fragment = document.createDocumentFragment();
+
+ var domain = this.domain;
+ if (this.initialIDs) {
+ domain = domain.concat([['id', 'in', this.initialIDs]]);
+ }
+ var ViewClass = view_registry.get(this.viewType);
+ var viewOptions = {};
+ var selectCreateController;
+ if (this.viewType === 'list') { // add listview specific options
+ _.extend(viewOptions, {
+ hasSelectors: !this.options.disable_multiple_selection,
+ readonly: true,
+
+ }, this.options.list_view_options);
+ selectCreateController = select_create_controllers_registry.SelectCreateListController;
+ }
+ if (this.viewType === 'kanban') {
+ _.extend(viewOptions, {
+ noDefaultGroupby: true,
+ selectionMode: this.options.selectionMode || false,
+ });
+ selectCreateController = select_create_controllers_registry.SelectCreateKanbanController;
+ }
+ var view = new ViewClass(fieldsViews[this.viewType], _.extend(viewOptions, {
+ action: {
+ controlPanelFieldsView: fieldsViews.search,
+ help: _.str.sprintf("<p>%s</p>", _t("No records found!")),
+ },
+ action_buttons: false,
+ dynamicFilters: this.options.dynamicFilters,
+ context: this.context,
+ domain: domain,
+ modelName: this.res_model,
+ withBreadcrumbs: false,
+ withSearchPanel: false,
+ }));
+ view.setController(selectCreateController);
+ return view.getController(this).then(function (controller) {
+ self.viewController = controller;
+ // render the footer buttons
+ self._prepareButtons();
+ return self.viewController.appendTo(fragment);
+ }).then(function () {
+ return fragment;
+ });
+ },
+ close: function () {
+ this._super.apply(this, arguments);
+ this.on_closed();
+ },
+ create_edit_record: function () {
+ var self = this;
+ var dialog = new FormViewDialog(this, _.extend({}, this.options, {
+ on_saved: function (record) {
+ var values = [{
+ id: record.res_id,
+ display_name: record.data.display_name || record.data.name,
+ }];
+ self.on_selected(values);
+ },
+ })).open();
+ dialog.on('closed', this, this.close);
+ return dialog;
+ },
+ /**
+ * @override
+ */
+ _focusOnClose: function() {
+ var isFocusSet = false;
+ this.trigger_up('form_dialog_discarded', {
+ callback: function (isFocused) {
+ isFocusSet = isFocused;
+ },
+ });
+ return isFocusSet;
+ },
+ /**
+ * prepare buttons for dialog footer based on options
+ *
+ * @private
+ */
+ _prepareButtons: function () {
+ this.__buttons = [{
+ text: _t("Cancel"),
+ classes: 'btn-secondary o_form_button_cancel',
+ close: true,
+ }];
+ if (!this.options.no_create) {
+ this.__buttons.unshift({
+ text: _t("Create"),
+ classes: 'btn-primary',
+ click: this.create_edit_record.bind(this)
+ });
+ }
+ if (!this.options.disable_multiple_selection) {
+ this.__buttons.unshift({
+ text: _t("Select"),
+ classes: 'btn-primary o_select_button',
+ disabled: true,
+ close: true,
+ click: function () {
+ var records = this.viewController.getSelectedRecords();
+ var values = _.map(records, function (record) {
+ return {
+ id: record.res_id,
+ display_name: record.data.display_name,
+ };
+ });
+ this.on_selected(values);
+ },
+ });
+ }
+ },
+});
+
+return {
+ FormViewDialog: FormViewDialog,
+ SelectCreateDialog: SelectCreateDialog,
+};
+
+});
diff --git a/addons/web/static/src/js/views/view_registry.js b/addons/web/static/src/js/views/view_registry.js
new file mode 100644
index 00000000..f936787b
--- /dev/null
+++ b/addons/web/static/src/js/views/view_registry.js
@@ -0,0 +1,44 @@
+odoo.define('web.view_registry', function (require) {
+"use strict";
+
+/**
+ * This module defines the view_registry. Web views are added to the registry
+ * in the 'web._view_registry' module to avoid cyclic dependencies.
+ * Views defined in other addons should be added in this registry as well,
+ * ideally in another module than the one defining the view, in order to
+ * separate the declarative part of a module (the view definition) from its
+ * 'side-effects' part.
+ */
+
+var Registry = require('web.Registry');
+
+return new Registry();
+
+});
+
+odoo.define('web._view_registry', function (require) {
+"use strict";
+
+/**
+ * The purpose of this module is to add the web views in the view_registry.
+ * This can't be done directly in the module defining the view_registry as it
+ * would produce cyclic dependencies.
+ */
+
+var FormView = require('web.FormView');
+var GraphView = require('web.GraphView');
+var KanbanView = require('web.KanbanView');
+var ListView = require('web.ListView');
+var PivotView = require('web.PivotView');
+var CalendarView = require('web.CalendarView');
+var view_registry = require('web.view_registry');
+
+view_registry
+ .add('form', FormView)
+ .add('list', ListView)
+ .add('kanban', KanbanView)
+ .add('graph', GraphView)
+ .add('pivot', PivotView)
+ .add('calendar', CalendarView);
+
+});
diff --git a/addons/web/static/src/js/views/view_utils.js b/addons/web/static/src/js/views/view_utils.js
new file mode 100644
index 00000000..82cefe5b
--- /dev/null
+++ b/addons/web/static/src/js/views/view_utils.js
@@ -0,0 +1,92 @@
+odoo.define('web.viewUtils', function (require) {
+"use strict";
+
+var dom = require('web.dom');
+var utils = require('web.utils');
+
+var viewUtils = {
+ /**
+ * Returns the value of a group dataPoint, i.e. the value of the groupBy
+ * field for the records in that group.
+ *
+ * @param {Object} group dataPoint of type list, corresponding to a group
+ * @param {string} groupByField the name of the groupBy field
+ * @returns {string | integer | false}
+ */
+ getGroupValue: function (group, groupByField) {
+ var groupedByField = group.fields[groupByField];
+ switch (groupedByField.type) {
+ case 'many2one':
+ return group.res_id || false;
+ case 'selection':
+ var descriptor = _.find(groupedByField.selection, function (option) {
+ return option[1] === group.value;
+ });
+ return descriptor && descriptor[0];
+ case 'char':
+ case 'boolean':
+ return group.value;
+ default:
+ return false; // other field types are not handled
+ }
+ },
+ /**
+ * States whether or not the quick create feature is available for the given
+ * datapoint, depending on its groupBy field.
+ *
+ * @param {Object} list dataPoint of type list
+ * @returns {Boolean} true iff the kanban quick create feature is available
+ */
+ isQuickCreateEnabled: function (list) {
+ var groupByField = list.groupedBy[0] && list.groupedBy[0].split(':')[0];
+ if (!groupByField) {
+ return false;
+ }
+ var availableTypes = ['char', 'boolean', 'many2one', 'selection'];
+ if (!_.contains(availableTypes, list.fields[groupByField].type)) {
+ return false;
+ }
+ return true;
+ },
+ /**
+ * @param {string} arch view arch
+ * @returns {Object} parsed arch
+ */
+ parseArch: function (arch) {
+ var doc = $.parseXML(arch).documentElement;
+ var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban';
+ return utils.xml_to_json(doc, stripWhitespaces);
+ },
+ /**
+ * Renders a button according to a given arch node element.
+ *
+ * @param {Object} node
+ * @param {Object} [options]
+ * @param {string} [options.extraClass]
+ * @param {boolean} [options.textAsTitle=false]
+ * @returns {jQuery}
+ */
+ renderButtonFromNode: function (node, options) {
+ var btnOptions = {
+ attrs: _.omit(node.attrs, 'icon', 'string', 'type', 'attrs', 'modifiers', 'options', 'effect'),
+ icon: node.attrs.icon,
+ };
+ if (options && options.extraClass) {
+ var classes = btnOptions.attrs.class ? btnOptions.attrs.class.split(' ') : [];
+ btnOptions.attrs.class = _.uniq(classes.concat(options.extraClass.split(' '))).join(' ');
+ }
+ var str = (node.attrs.string || '').replace(/_/g, '');
+ if (str) {
+ if (options && options.textAsTitle) {
+ btnOptions.attrs.title = str;
+ } else {
+ btnOptions.text = str;
+ }
+ }
+ return dom.renderButton(btnOptions);
+ },
+};
+
+return viewUtils;
+
+});