summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views/basic
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/basic
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views/basic')
-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
5 files changed, 7480 insertions, 0 deletions
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();
+});