diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index bb58bf57d3a..87af9250a1f 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -177,6 +177,7 @@ This module provides the core of the Odoo Web Client. 'web/static/src/core/utils/transitions.scss', 'web/static/src/core/**/*', + 'web/static/src/model/**/*', 'web/static/src/search/**/*', 'web/static/src/webclient/icons.scss', # variables required in list_controller.scss 'web/static/src/views/**/*', @@ -228,7 +229,6 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/utils.js', 'web/static/src/legacy/pseudo_web_client.js', 'web/static/src/legacy/web_client.js', - 'web/static/src/legacy/js/_deprecated/*', 'web/static/src/legacy/js/chrome/*', 'web/static/src/legacy/js/components/*', 'web/static/src/legacy/js/control_panel/*', @@ -241,7 +241,6 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/js/services/data_manager.js', 'web/static/src/legacy/js/services/session.js', 'web/static/src/legacy/js/tools/tools.js', - 'web/static/src/legacy/js/views/**/*', 'web/static/src/legacy/js/widgets/colorpicker_dialog.js', 'web/static/src/legacy/js/widgets/date_picker.js', 'web/static/src/legacy/js/widgets/domain_selector_dialog.js', diff --git a/addons/web/static/src/core/domain.js b/addons/web/static/src/core/domain.js index bff10755bdd..889dde0b0c3 100644 --- a/addons/web/static/src/core/domain.js +++ b/addons/web/static/src/core/domain.js @@ -174,6 +174,18 @@ export class Domain { } } +/** + * @param {Array[] | boolean} modifier + * @param {Object} evalContext + * @returns {boolean} + */ +export function evalDomain(modifier, evalContext) { + if (modifier && typeof modifier !== "boolean") { + modifier = new Domain(modifier).contains(evalContext); + } + return Boolean(modifier); +} + /** @type {Condition} */ const TRUE_LEAF = [1, "=", 1]; /** @type {Condition} */ diff --git a/addons/web/static/src/core/utils/reactive.js b/addons/web/static/src/core/utils/reactive.js index c4770ebf070..ebf48988657 100644 --- a/addons/web/static/src/core/utils/reactive.js +++ b/addons/web/static/src/core/utils/reactive.js @@ -31,7 +31,7 @@ export class Reactive { * Creates a side-effect that runs based on the content of reactive objects. * * @template {object[]} T - * @param {(...args: [...T]) => void} cb callback for the effect + * @param {(...args: [...T]) => X} cb callback for the effect * @param {[...T]} deps the reactive objects that the effect depends on */ export function effect(cb, deps) { diff --git a/addons/web/static/src/core/utils/timing.js b/addons/web/static/src/core/utils/timing.js index 03246e2c172..1f5f43dee0a 100644 --- a/addons/web/static/src/core/utils/timing.js +++ b/addons/web/static/src/core/utils/timing.js @@ -3,6 +3,25 @@ import { browser } from "@web/core/browser/browser"; import { onWillUnmount, useComponent } from "@odoo/owl"; +/** + * Creates a batched version of a callback so that all calls to it in the same + * time frame will only call the original callback once. + * @param callback the callback to batch + * @param synchronize this function decides the granularity of the batch (a microtick by default) + * @returns a batched version of the original callback + */ +export function batched(callback, synchronize = () => Promise.resolve()) { + let scheduled = false; + return async (...args) => { + if (!scheduled) { + scheduled = true; + await synchronize(); + scheduled = false; + callback(...args); + } + }; +} + /** * Creates and returns a new debounced version of the passed function (func) * which will postpone its execution until after 'delay' milliseconds diff --git a/addons/web/static/src/legacy/js/_deprecated/crash_manager_warning_dialog.js b/addons/web/static/src/legacy/js/_deprecated/crash_manager_warning_dialog.js deleted file mode 100644 index 9697257d59f..00000000000 --- a/addons/web/static/src/legacy/js/_deprecated/crash_manager_warning_dialog.js +++ /dev/null @@ -1,30 +0,0 @@ -/** @odoo-module */ -import Dialog from "web.Dialog"; -import core from "web.core"; - -/** - * An extension of Dialog Widget to render warnings. - */ -export const WarningDialog = Dialog.extend({ - template: 'web.LegacyWarningDialog', - /** - * @param {Object} error - * @param {string} error.message the message in Warning Dialog - * - * @constructor - */ - init: function (parent, options, error) { - options.size = options.size || "medium"; - this._super.apply(this, [parent, options]); - this.message = error.message; - core.bus.off('close_dialogs', this); - }, - /** - * Focuses the ok button. - * - * @override - */ - open: function () { - this._super({shouldFocusButtons: true}); - }, -}); diff --git a/addons/web/static/src/legacy/js/views/abstract_model.js b/addons/web/static/src/legacy/js/views/abstract_model.js deleted file mode 100644 index 439829eb85f..00000000000 --- a/addons/web/static/src/legacy/js/views/abstract_model.js +++ /dev/null @@ -1,124 +0,0 @@ -/** @odoo-module alias=web.AbstractModel **/ - -/** - * An AbstractModel is the M in MVC. We tend to think of MVC more on the server - * side, but we are talking here on the web client side. - * - * The duties of the Model are to fetch all relevant data, and to make them - * available for the rest of the view. Also, every modification to that data - * should pass through the model. - * - * Note that the model is not a widget, it does not need to be rendered or - * appended to the dom. However, it inherits from the EventDispatcherMixin, - * in order to be able to notify its parent by bubbling events up. - * - * The model is able to generate sample (fake) data when there is no actual data - * in database. This feature can be activated by instantiating the model with - * param "useSampleModel" set to true. In this case, the model instantiates a - * duplicated version of itself, parametrized to call a SampleServer (JS) - * instead of doing RPCs. Here is how it works: the main model first load the - * data normally (from database), and then checks whether the result is empty or - * not. If it is, it asks the sample model to load with the exact same params, - * and it thus enters in "sample" mode. The model keeps doing this at reload, - * but only if the (re)load params haven't changed: as soon as a param changes, - * the "sample" mode is left, and it never enters it again in the future (in the - * lifetime of the model instance). To access those sample data from the outside, - * 'get' must be called with the option "withSampleData" set to true. In - * this case, if the main model is in "sample" mode, it redirects the call to the - * sample model. - */ - -import fieldUtils from 'web.field_utils'; -import mvc from 'web.mvc'; - - -var AbstractModel = mvc.Model.extend({ - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - * @param {any} _ - * @param {Object} [options] - */ - get(_, options) { - return this.__get(...arguments); - }, - /** - * @override - */ - load(params) { - this.loadParams = params; - return this.__load(...arguments); - }, - /** - * When something changes, the data may need to be refetched. This is the - * job for this method: reloading (only if necessary) all the data and - * making sure that they are ready to be redisplayed. - * - * @param {any} _ - * @param {Object} [params] - * @returns {Promise} - */ - async reload(_, params) { - return this.__reload(...arguments); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @returns {Object} - */ - __get() { - return {}; - }, - /** - * To override to do the initial load of the data (this function is supposed - * to be called only once). - * - * @private - * @returns {Promise} - */ - async __load() { - return Promise.resolve(); - }, - /** - * Processes date(time) and selection field values sent by the server. - * Converts data(time) values to moment instances. - * Converts false values of selection fields to 0 if 0 is a valid key, - * because the server doesn't make a distinction between false and 0, and - * always sends false when value is 0. - * - * @param {Object} field the field description - * @param {*} value - * @returns {*} the processed value - */ - _parseServerValue: function (field, value) { - if (field.type === 'date' || field.type === 'datetime') { - // process date(time): convert into a moment instance - value = fieldUtils.parse[field.type](value, field, {isUTC: true}); - } else if (field.type === 'selection' && value === false) { - // process selection: convert false to 0, if 0 is a valid key - var hasKey0 = field.selection.find(option => option[0] === 0); - value = hasKey0 ? 0 : value; - } - return value; - }, - /** - * To override to reload data (this function may be called several times, - * after the initial load has been done). - * - * @private - * @returns {Promise} - */ - async __reload() { - return Promise.resolve(); - }, -}); - -export default AbstractModel; diff --git a/addons/web/static/src/legacy/js/views/basic/basic_model.js b/addons/web/static/src/legacy/js/views/basic/basic_model.js deleted file mode 100644 index 803d7591bf6..00000000000 --- a/addons/web/static/src/legacy/js/views/basic/basic_model.js +++ /dev/null @@ -1,5404 +0,0 @@ -/** @odoo-module alias=web.BasicModel **/ - -/** - * 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. - */ - -import { intersection, sortBy, unique } from "@web/core/utils/arrays"; -import { uniqueId } from "@web/core/utils/functions"; -import { omit, pick } from "@web/core/utils/objects"; -import { sprintf } from "@web/core/utils/strings"; -import AbstractModel from "web.AbstractModel"; -import concurrency from "web.concurrency"; -import Context from "web.Context"; -import core from "web.core"; -import Domain from "web.Domain"; -import session from "web.session"; -import utils from "web.utils"; -import viewUtils from "web.viewUtils"; - -var _t = core._t; - -const SENTINEL = null; - -// 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 - - /** - * @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.bypassMutex = false; // never set this to true manually (see @executeDirectly) - - // 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} resolves to the id of the new created record - */ - addDefaultRecord: function (listID, options) { - var self = this; - var list = this.localData[listID]; - var context = Object.assign({}, 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 = Object.assign({}, 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] = Object.assign( - {}, - mainFieldInfo, - viewInfo.fieldInfo - ); - - // 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 = Object.assign({}, 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} 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 = parent._savePoint.find((p) => p.operation === "ADD" && p.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 = parent._savePoint.at(-1); - 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 = recordIds.map((id) => self.localData[id]); - var context = Object.assign(records[0].getContext(), session.user_context); - return this._rpc({ - model: modelName, - method: 'unlink', - args: [records.map(r => r.res_id)], - context: context, - }) - .then(function () { - records.forEach((record) => { - // discard any changes to prevent creating a new record with auto-save - self.discardChanges(record.id); - var parent = record.parentID && self.localData[record.parentID]; - if (parent && parent.type === 'list') { - parent.data = parent.data.filter((x) => x !== 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--; - } - }); - }); - }, - /** - * 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 = Object.assign({}, 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; - } - elem.specialData = Object.assign({}, elem._specialDataSavepoint); - }); - element.offset = initialOffset; - }, - /** - * Duplicate a record (by calling the 'copy' route) - * - * @param {string} recordID id for a local resource - * @returns {Promise} 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, - }); - }); - }, - /** - * This method allows to execute a callback for which '_notifyChanges' and - * 'save' will bypass the mutex. This is useful when we are leaving Odoo - * (closing tab/browser), and we want to quickly save pending changes (in - * an 'onbeforeunload' handler, which is mostly sync). - * - * This function should never be called except when we are leaving Odoo. - * - * @param {Function} callback - */ - executeDirectly(callback) { - this.bypassMutex = true; - callback(); - this.bypassMutex = false; - }, - /** - * 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 = Object.assign({}, 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: Object.assign({}, 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, - isNew: element.isNew, - limit: element.limit, - model: element.model, - offset: element.offset, - ref: element.ref, - res_ids: element.res_ids.slice(0), - specialData: Object.assign({}, 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) { - element._changes.forEach((change) => { - if (change.operation === 'ADD' && change.isNew) { - element.data = element.data.filter((x) => x !== change.id); - if (change.position === 'top') { - element.data.unshift(change.id); - } else { - element.data.push(change.id); - } - } - }); - } - - var list = { - aggregateValues: Object.assign({}, element.aggregateValues), - context: Object.assign({}, element.context), - count: element.count, - data: element.data.map((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, - isM2MGrouped: element.isM2MGrouped, - isNew: element.isNew, - 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, - range: element.range, - 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} 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} the local id for the created resource - */ - makeRecord: function (model, fields, fieldInfo) { - var self = this; - var defs = []; - var record_fields = {}; - fields.forEach((field) => { - record_fields[field.name] = pick(field, 'type', 'relation', 'domain', 'selection'); - }); - fieldInfo = fieldInfo || {}; - var fieldsInfo = {}; - fieldsInfo.default = {}; - fields.forEach((field) => { - fieldsInfo.default[field.name] = fieldInfo[field.name] || {}; - }); - var record = this._makeDataPoint({ - modelName: model, - fields: record_fields, - fieldsInfo: fieldsInfo, - viewType: 'default', - }); - fields.forEach((field) => { - var dataPoint; - record.data[field.name] = null; - if (field.type === 'many2one') { - if (field.value) { - var id = Array.isArray(field.value) ? field.value[0] : field.value; - var display_name = Array.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 = {}; - (field.fields || []).forEach((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 (typeof field.value[0] === "object") { - dpParams.res_ids = field.value.map((v) => v.id); - dataPoint = self._makeDataPoint(dpParams); - field.value.forEach((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} list of changed fields - */ - notifyChanges: function (record_id, changes, options) { - const notifyChanges = () => this._applyChange(record_id, changes, options); - if (this.bypassMutex) { - return notifyChanges(); - } - return this.mutex.exec(notifyChanges); - }, - /** - * 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} 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 = parent._changes.filter( 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 = parent.data.filter((x) => x !== elementID); - parent.count--; - } - }, - /** - * Resequences records. - * - * @param {string} modelName the resIDs model - * @param {Array} 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} 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, - context: data.getContext(), - }; - 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]], - context: data.getContext(), - }).then(function (records) { - if (data.data.length) { - var dataType = self.localData[data.data[0]].type; - if (dataType === 'record') { - data.data.forEach((dataPoint) => { - var recordData = self.localData[dataPoint].data; - var inRecords = records.find(({ id }) => 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 resIDs.indexOf(self.localData[d].res_id); - }); - } - } - data.res_ids = []; - data.data.forEach((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; - function _save() { - 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 = Object.assign({}, 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(); - fieldNames.forEach((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 = {}; - var data = Object.assign({}, record.data, record._changes); - for (var fieldName in record.fields) { - var type = record.fields[fieldName].type; - if (type === 'many2many' || type === 'one2many') { - if (data[fieldName]) { - self.localData[data[fieldName]]._changes = []; - } - } - } - - self.unfreezeOrder(record.id); - - // Update the data directly or reload them - if (shouldReload) { - self._fetchRecord(record).then( - function () { - resolve(changedFields); - }, - reject, - ); - } else { - Object.assign(record.data, _changes); - - record._changes = {}; - for (const fieldName in record.fields) { - if (['many2many', 'one2many'].includes(record.fields[fieldName].type) && record.data[fieldName]) { - self.localData[record.data[fieldName]]._changes = []; - } - } - resolve(changedFields); - } - }).guardedCatch(reject); - - } else { - resolve(changedFields); - } - }); - prom.then(function () { - self._updateDuplicateRecords(record.id, (id) => { - Object.assign(self.localData[id].data, record.data); - }); - record._isDirty = false; - }); - return prom; - } - if (this.bypassMutex) { - return _save(); - } else { - return this.mutex.exec(_save); - } - }, - /** - * 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) { - list._changes.forEach((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 = list.orderedBy.filter(o => 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 - }, - /** - * 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} 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) { - if (Object.keys(action || {}).length > 0) { - 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} 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 - if (Object.keys(action || {}).length > 0) { - 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} 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 = Object.assign({}, 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){ - additionalContexts.forEach((context) => { - params.context = self._getContext(list, {additionalContext: context, full: true, sanitize_default_values: true}); - makeDefaultRecords.push(self._makeDefaultRecord(list.model, params)); - }); - } else { - params.context = self._getContext(list, {sanitize_default_values: true}); - makeDefaultRecords.push(self._makeDefaultRecord(list.model, params)); - } - - return Promise.all(makeDefaultRecords).then(function (resultIds){ - var ids = []; - resultIds.forEach((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(Object.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(Object.keys(changes).concat(Object.keys(result && result.value || {}))); - }).guardedCatch(function () { - self._visitChildren(record, function (elem) { - Object.assign(elem, initialData[elem.id]); - }); - reject(); - }); - } else { - resolve(Object.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 fieldNames.concat(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 read of 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'); - - const viewType = options.viewType || record.viewType; - const fieldInfo = record.fieldsInfo[viewType][fieldName] || {}; - const fieldOptions = fieldInfo.options || {}; - - // 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 || fieldOptions.always_reload) { - // TODO: refactor this to use _fetchNameGet - def = this._rpc({ - model: coModel, - method: 'read', - args: [data.id, ['display_name']], - context: this._getContext(record, { fieldName, viewType }), - }) - .then(function (result) { - rel_data.display_name = result[0]['display_name']; - }); - } - 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 = Array.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 if (oldValue !== false) { - // if no value is provided for a m2o, - // we should only consider the field updated if it had a value before - 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; - val.forEach((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 = oldChanges.find(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'}]; - Object.values(command[2] || {}).forEach((resID) => { - linkRecord(list, resID); - }); - } - }); - var def = self._readUngroupedList(list).then(function (list) { - 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]; - list.fields = Object.assign(newRecord.fields, list.fields); - list.fieldsInfo = Object.assign(newRecord.fieldsInfo, list.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 = Array.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 = {}; - data.forEach((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: [data.map((d) => d.id), fieldNames], - context: Object.assign({}, record.context, field.context, list.getContext()), - }).then(function (records) { - records.forEach((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 = Object.assign({ - context: command.context, - position: command.position - }, options || {}); - createOptions.viewType = fieldInfo.mode; - - def = this._addX2ManyDefaultRecord(list, createOptions).then(function (ids) { - ids.forEach((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 = list._changes.filter(change => { - var idInCommands = command.ids.includes(change.id); - if (idInCommands && change.operation === 'ADD') { - idsToRemove = idsToRemove.filter(id => id !== change.id); - } - return !idInCommands; - }); - idsToRemove.forEach(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 = list._changes.filter(change => 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 = command.ids.filter((x) => list.res_ids.indexOf(x) < 0); - var removedIds = list.res_ids.filter((x) => command.ids.indexOf(x) < 0); - var addDef, removedDef, values; - if (newIds.length) { - values = newIds.map((id) => { - return { id: id }; - }); - addDef = this._applyX2ManyChange(record, fieldName, { - operation: 'ADD_M2M', - ids: values - }, options); - } - if (removedIds.length) { - var listData = list.data.map((localId) => self.localData[localId]); - removedDef = this._applyX2ManyChange(record, fieldName, { - operation: 'DELETE', - ids: removedIds.map((resID) => { - if (resID in list._cache) { - return list._cache[resID]; - } - return listData.find((list) => list.res_id === resID).id; - }), - }, options); - } - return Promise.all([addDef, removedDef]); - case 'MULTI': - // allows batching multiple operations - command.commands.forEach((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 = Object.assign({}, 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); - } - changes.forEach((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 = list.res_ids.filter((resID) => resID !== 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 || ''; - Object.keys(fieldsInfo).forEach((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') { - Object.values(fieldInfo.views || {}).forEach((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(sprintf('for modifier "%s": %s', k, e.message)); - } - } - return evaluated; - }, - /** - * Fetch the type of model (e.g. 'res.partner') contained in the model_field - * @private - * @param {Object} record - an element from the localData - * @param {Object} fieldName - the name of the field - * @param {Object} fieldInfo - the info of the field - * @returns {Promise} - */ - _fetchModelFieldReference: async function (record, fieldName, fieldInfo) { - const modelField = fieldInfo.options.model_field; - const value = record._changes && record._changes[modelField] || record.data[modelField]; - if (value) { - if (record.fields[modelField].type !== "many2one" - || record.fields[modelField].relation !== 'ir.model') { - throw new Error(`The model_field of the reference field - ${fieldName} must be a many2one('ir.model').`); - } - const modelId = this.localData[value].res_id; - const resourceRef = record.specialData[fieldName]; - if (resourceRef && modelId === resourceRef.modelId) { - resourceRef.hasChanged = false; - return Promise.resolve(resourceRef); - } else { - const result = await this._rpc({ - model: 'ir.model', - method: 'read', - args: [modelId, ['id', 'model']], - }); - // Checks the case where the data on the backend side are not synchronized - // (modelFieldName != referenceFieldName) when opening the edit view (!_changes). - // We want to avoid resynchronization in order not to modify the data - // without being requested. - if (!record._changes && record.data[fieldName] - && result[0].model !== this.localData[record.data[fieldName]].model) { - const modelFieldName = record.fields[modelField].string; - const referenceFieldName = record.fields[fieldName].string; - - this.displayNotification({ - title: sprintf( - _t(`'%s' is unsynchronized with '%s'.`), - referenceFieldName, - modelFieldName, - ), - message: sprintf( - _t(`If you change %s or %s, the synchronization will be reapplied and the data will be modified.`), - modelFieldName, - referenceFieldName, - ), - sticky: true, - type: 'danger', - }); - return false; - } - return { - modelName: result[0].model, - modelId: result[0].id, - hasChanged: true, - }; - } - } - }, - /** - * Fetch display_name for a record datapoint. - * - * @param {Object} dataPoint - * @returns {Promise} - */ - _fetchNameGet: function (dataPoint) { - return this._rpc({ - model: dataPoint.model, - method: 'read', - args: [dataPoint.res_id, ['display_name']], - context: dataPoint.getContext(), - }).then(function (result) { - dataPoint.data.display_name = result[0]['display_name']; - }); - }, - /** - * Fetch display_name 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 forEach below will not make it. - var model = list.fields[fieldName].relation; - var records = []; - var ids = []; - list = this._applyX2ManyOperations(list); - - list.data.forEach((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: 'read', - args: [unique(ids), ['display_name']], - context: list.context, - }) - .then(function (readRecords) { - records.forEach((record) => { - const readRecord = readRecords.find(readRecord => readRecord['id'] === record.data.id); - record.data.display_name = readRecord['display_name']; - }); - }); - }, - /** - * 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} 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 = unique(fieldNames.concat(['display_name'])); - return this._rpc({ - model: record.model, - method: 'read', - args: [[record.res_id], fieldNames], - context: Object.assign({bin_size: true}, record.getContext()), - }) - .then(function (result) { - if (result.length === 0) { - return Promise.reject(); - } - result = result[0]; - record.data = Object.assign({}, 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 `display_name` 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: 'read', - args: [resID, ['display_name']], - context: record.getContext({fieldName: fieldName}), - }).then(function (result) { - return self._makeDataPoint({ - data: result[0], - 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 = Object.keys(datapoints).map((id) => 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: 'read', - args: [ids, ['display_name']], - context: self.localData[parent].getContext({fieldName: fieldName, withoutRecordData: true}), - }).then(function (result) { - result.forEach((el) => { - var parentIDs = datapoints[el['id']]; - parentIDs.forEach((parentID) => { - var parent = self.localData[parentID]; - var referenceDp = self._makeDataPoint({ - data: el, - modelName: model, - parentID: parent.id, - }); - parent.data[fieldName] = referenceDp.id; - }); - }); - }); - return def; - }, - /** - * Fetch the extra data (`display_name`) 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(); - fieldNames.forEach((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 display_name read by model - Object.entries(toFetch).forEach(([model, datapoints]) => { - 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 = {}; - list.data.forEach((groupIndex) => { - var group = self.localData[groupIndex]; - self._getDataToFetchByModel(group, fieldName, toFetch); - }); - - var defs = []; - // one display_name read by model - Object.entries(toFetch).forEach(([model, datapoints]) => { - 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 display_name 'read' 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 = Object.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 = Object.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({withoutRecordData: true}) || {}, - }); - } else { - def = Promise.resolve( - ids.map((id) => { - return { id: id }; - }) - ); - } - return def.then(function (result) { - var records = unique(Object.values(toFetch || {}).flat()); - 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} - * 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(fieldNames.map((name) => { - var viewType = (options && options.viewType) || record.viewType; - var fieldInfo = record.fieldsInfo[viewType][name] || {}; - let specialData; - if ("specialData" in fieldInfo) { - specialData = fieldInfo.specialData; - } else if (fieldInfo.Widget) { - specialData = fieldInfo.Widget.prototype.specialData; - } - if (specialData) { - return self[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} - * 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(); - } - - return this._rpc({ - model: field.relation, - method: 'search_read', - fields: ["display_name"].concat(fieldsToRead || []), - context: context, - domain: domain, - }); - }, - /** - * 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} - * 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 (!["many2one", "many2many", "one2many"].includes(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 `display_name` 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 - * @param {Object} fieldInfo - the info of the field - * @returns {Promise} - */ - _fetchSpecialReference: function (record, fieldName, fieldInfo) { - var field = record.fields[fieldName]; - if (field.type === 'char') { - // if the widget reference is set on a char field, the display_name - // needs to be fetched a posteriori - return Promise.resolve(this._fetchReference(record, fieldName)); - } else if (fieldInfo.options.model_field) { - return this._fetchModelFieldReference(record, fieldName, fieldInfo); - } - return Promise.resolve(); - }, - /** - * 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} - * 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) { - Object.values(m2os || {}).forEach((m2o) => { - m2o.fold = foldField ? m2o[foldField] : false; - }); - return m2os; - }); - }, - /** - * 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} resolves to the fecthed list - */ - _fetchUngroupedList: function (list, options) { - options = Object.assign({ enableRelationalFetch: true }, options || {}); - 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 = many2ones.map((name) => 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; - fieldNames.forEach((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] || []; - - // remove default_* & *_view_ref keys from record context - const recordContext = { ...record.context }; - for (const key in recordContext) { - if (key.startsWith("default_") || key.endsWith("_view_ref")) { - delete recordContext[key]; - } - } - - var list = self._makeDataPoint({ - count: ids.length, - context: Object.assign({}, recordContext, 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, { withoutRecordData: true }).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 = {}; - list.data.forEach((groupIndex) => { - var group = self.localData[groupIndex]; - var nextDataToFetch = self._getDataToFetch(group, fieldName); - Object.keys(nextDataToFetch).forEach((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 = Object.assign({}, record.data, record._changes); - } else { - changes = Object.assign({}, 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 = Object.assign({}, options || {}, {withReadonly: true}); - var data = {}; - if (!options.firstOnChange) { - var commands = this._generateX2ManyCommands(record, options); - data = Object.assign(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 = Object.values(record.fieldsInfo || {}).some((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 || {}; - const changesOnly = options.changesOnly; - var fields = record.fields; - if (options.fieldNames) { - fields = pick(fields, ...options.fieldNames); - } - var commands = {}; - var data = Object.assign({}, 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 = []; - Object.values(list._changes || {}).forEach((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 = relRecordAdded.filter(rec => typeof rec.res_id === 'string'); - const relRecordCreatedResId = relRecordCreated.map(r => r.res_id) - var realIDs = list.res_ids.filter((x) => relRecordCreatedResId.indexOf(x) < 0) - // 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)); - relRecordCreated.forEach((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) - relRecordUpdated.forEach((relRecord) => { - var changes = self._generateChanges(relRecord, options); - if (Object.keys(changes || {}).length > 0) { - var command = x2ManyCommands.update(relRecord.res_id, changes); - commands[fieldName].push(command); - } - }); - } else if (type === 'one2many') { - var removedIds = oldResIDs.filter((x) => list.res_ids.indexOf(x) < 0); - var addedIds = list.res_ids.filter((x) => oldResIDs.indexOf(x) < 0); - 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 (keptIds.includes(list.res_ids[i])) { - // this is an id that already existed - relRecord = relRecordUpdated.find((record) => record.res_id === list.res_ids[i]); - changes = relRecord ? this._generateChanges(relRecord, options) : {}; - if (Object.keys(changes || {}).length > 0) { - command = x2ManyCommands.update(relRecord.res_id, changes); - didChange = true; - } else { - command = x2ManyCommands.link_to(list.res_ids[i]); - } - commands[fieldName].push(command); - } else if (addedIds.includes(list.res_ids[i])) { - // this is a new id (maybe existing in DB, but new in JS) - relRecord = relRecordAdded.find((record) => record.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 (changesOnly ? Object.keys(changes).length : 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 - * @param {boolean} [options.withoutRecordData=false] - * if true the record data will be replaced by a sentinel in the eval - * context (useful for read rpcs, where we don't want the context to - * depend on data) - * @returns {Object} the evaluated context - */ - _getContext: function (element, options) { - options = options || {}; - var context = new Context(session.user_context); - - if (options.full || !(options.fieldName || options.additionalContext)) { - const keys_to_omit = Object.keys(element.context || {}).filter((key) => String(key).startsWith('default_')); - var context_to_add = options.sanitize_default_values ? - omit(element.context, ...keys_to_omit) - : element.context; - context.add(context_to_add); - } - if (options.fieldName) { - var viewType = options.viewType || element.viewType; - var fieldInfo = element.fieldsInfo[viewType][options.fieldName]; - if (fieldInfo && fieldInfo.context) { - if (fieldInfo.context !== "{}") { - context.add(fieldInfo.context); - } - } else { - var fieldParams = element.fields[options.fieldName]; - if (fieldParams.context) { - context.add(fieldParams.context); - } - } - } - if (element.rawContext) { - var rawContext = new Context(element.rawContext); - var evalContext = this._getEvalContext(this.localData[element.parentID], false, options.withoutRecordData); - evalContext.id = evalContext.id || false; - rawContext.set_eval_context(evalContext); - context.add(rawContext); - } - if (options.additionalContext) { - context.add(options.additionalContext); - } - - if (context.__contexts.length > 1) { - context.set_eval_context(this._getEvalContext(element, false, options.withoutRecordData)); - const evaluatedContext = context.eval(); - if (options.withoutRecordData) { - for (const key in evaluatedContext) { - if (evaluatedContext[key] === SENTINEL) { - delete evaluatedContext[key]; - } - } - if (evaluatedContext.parent) { - for (const key in evaluatedContext.parent) { - if (evaluatedContext.parent[key] === SENTINEL) { - delete evaluatedContext.parent[key]; - } - } - } - } - return evaluatedContext; - } else { - return Object.assign({}, session.user_context); - } - }, - /** - * 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; - } - - Object.values(record.data[fieldName] || {}).forEach((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 || {}; - list.data.forEach((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, parent.groupedBy[0]); - 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 - * @param {boolean} [withoutRecordData=false] if true, replaces the record - * values by null (same inside the parent key) - * @returns {Object} - */ - _getEvalContext: function (element, forDomain, withoutRecordData) { - var evalContext = element.type === 'record' ? this._getRecordEvalContext(element, forDomain) : {}; - if (withoutRecordData) { - const entries = Object.keys(evalContext).map((key) => [key, SENTINEL]); - evalContext = Object.fromEntries(entries); - } - 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); - if (withoutRecordData) { - const entries = Object.keys(evalContext.parent).map((key) => [key, SENTINEL]); - evalContext.parent = Object.fromEntries(entries); - } - } - } - // 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 : - 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, - }, - session.user_context, - element.context, - evalContext, - ); - }, - - _getLazyEvalContext: function (element, forDomain) { - var evalContext = element.type === 'record' ? this._getLazyRecordEvalContext(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._getLazyRecordEvalContext(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 : - false; - } - let fallbackContext = Object.assign( - { - active_id: evalContext.id || false, - active_ids: evalContext.id ? [evalContext.id] : [], - active_model: element.model, - current_company_id, - id: evalContext.id || false, - }, - session.user_context, - element.context, - ); - - return new Proxy( - {}, { - has(target, key) { - return key in target || key in evalContext || key in fallbackContext; - }, - get (target, key) { - switch (true) { - case key in target: - return target[key]; - case key in evalContext: - return evalContext[key]; - default: - return fallbackContext[key];; - } - }, - ownKeys(target) { - return [...new Set([...Reflect.ownKeys(target), ...Object.keys(fallbackContext), ...Object.keys(evalContext)])]; - }, - getOwnPropertyDescriptor(target, prop) { - return Reflect.getOwnPropertyDescriptor(target, prop) || { writable: true, configurable: true, enumerable: true }; - }, - } - ); - }, - /** - * 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 a subset of a list resource keeping only the props to keep. - * This permits to a web_read_group to keep some of the properties - * of a previous list if it is still in its results. - * - * @private - * @param {Object} list - * @param {boolean} options.onlyGroup true when we only fetched the group's own data - * @param {boolean} options.hasSubgroups true when the fetched group has subgroups - * @returns {Object} subset of list - */ - _getGroupedListPropsToKeep(list, options) { - const { isOpen, offset, id } = list; - let propsToKeep = { isOpen, offset, id }; - - if (options.onlyGroup || isOpen && options.hasSubgroups) { - // 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 - propsToKeep.data = list.data; - if (options.onlyGroup) { - // 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. - propsToKeep.res_ids = list.res_ids; - propsToKeep.count = list.count; - } - } - - return propsToKeep; - }, - /** - * 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 = []; - for (const [name, field] of Object.entries(datapoint.fields)) { - 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' - * @returns Object - */ - _getRecordEvalContext: function (record) { - var relDataPoint; - var context = Object.assign({}, record.data, record._changes); - - 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] || Array.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 (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 = ids.filter(id => typeof id !== 'string'); - } - context[fieldName] = ids; - } - - } - return context; - }, - - _getLazyRecordEvalContext: function (record) { - let self = this; - let relDataPoint; - - return new Proxy({}, { - has(target, key) { - return key in target || key in record.fields; - }, - ownKeys(target) { - return [...new Set([...Reflect.ownKeys(target), ...Object.keys(record.fields)])]; - }, - getOwnPropertyDescriptor(target, prop) { - return Reflect.getOwnPropertyDescriptor(target, prop) || { writable: true, configurable: true, enumerable: true }; - }, - get( target, fieldName ) { - if (fieldName in target){ - return target[fieldName]; - } - const fieldValue = record._changes && fieldName in record._changes ? record._changes[fieldName] : record.data[fieldName]; - const field = record.fields[fieldName]; - if (fieldValue === null) { - return false; - } - if (fieldName === "id" && !fieldValue) { - return false; - } - - if (field.type === 'date' || field.type === 'datetime') { - if (fieldValue) { - return JSON.parse(JSON.stringify(fieldValue)); - } - } - if (field.type === 'many2one') { - relDataPoint = self.localData[fieldValue]; - return relDataPoint ? relDataPoint.res_id : false; - } - if (field.type === 'one2many' || field.type === 'many2many') { - let ids; - if (!fieldValue || Array.isArray(fieldValue)) { // no dataPoint created yet - ids = fieldValue ? fieldValue.slice(0) : []; - } else { - relDataPoint = self._applyX2ManyOperations(self.localData[fieldValue]); - ids = relDataPoint.res_ids.slice(0); - } - 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 = ids.filter(id => typeof id !== 'string'); - } - return ids; - } - if (field.type === "properties" && Array.isArray(fieldValue)) { - // remove deleted properties to be able - // to filter based on empty properties field - return fieldValue.filter(definition => !definition.definition_deleted); - } - return fieldValue; - } - }); - }, - /** - * Get the domain for a list resource. - * - * @private - * @param {Object} list - * @returns {Array[]} list domain - */ - _getUngroupedListDomain(list) { - return list.domain || []; - }, - /** - * 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]; - Object.values(element._changes || {}).forEach((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]; - element.getFieldNames().forEach((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 {Object} [params.range] only for datapoints representing groups - * coming from a groupBy on a date(time) field @see _readGroup format: - * {[fieldName:granularity]: {from: string, to: string}}, where - * 'from' (inclusive) and 'to' (exclusive) are the group bounds under the - * respective date format - * @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; - var fields = Object.assign({ - display_name: {type: 'char'}, - id: {type: 'integer'}, - }, params.fields); - - const groupedBy = params.groupedBy || []; - let isM2MGrouped = false; - 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; - isM2MGrouped = groupedBy.some((group) => { - const [fieldName] = group.split(':'); - return fields[fieldName].type === "many2many"; - }); - } - - 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, - groupsCount: 0, - groupsLimit: type === 'list' && params.groupsLimit || null, - groupsOffset: 0, - id: `${params.modelName}_${++this.__id}`, - isM2MGrouped, - isOpen: params.isOpen, - limit: type === 'record' ? 1 : (params.limit || Number.MAX_SAFE_INTEGER), - loadMoreOffset: 0, - model: params.modelName, - offset: params.offset || (type === 'record' ? res_ids.indexOf(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, - range: params.range, - 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); - dataPoint.isNew = this.isNew.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} 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 = fieldNames.concat(Object.keys(parentRecord.fieldsInfo[originView])); - fieldsInfo[targetView] = Object.assign({}, parentRecord.fieldsInfo[originView], fieldsInfo[targetView]); - fields = Object.assign({}, parentRecord.fields, 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; - fieldNames.forEach((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 = Object.assign({}, 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] = Object.assign({}, request, {requests: [request]}); - } else { - batches[key].ids = unique(batches[key].ids.concat(request.ids)); - batches[key].fieldNames = unique(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} resolves to the finished resource - */ - _postprocess: function (record, options) { - var self = this; - var viewType = options && options.viewType || record.viewType; - var defs = []; - - record.getFieldNames(options).forEach((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 display_name read - } - var relRecord = self.localData[relRecordId]; - defs.push(self._rpc({ - model: field.relation, - method: 'read', - args: [relRecord.data.id, ['display_name']], - context: self._getContext(record, {fieldName: name, viewType: viewType, withoutRecordData: true}), - }) - .then(function (result) { - relRecord.data.display_name = result[0]['display_name']; - })); - } - } - }); - - defs.push(this._fetchSpecialData(record, options)); - - return Promise.all(defs).then(function () { - record._specialDataSavepoint = Object.assign({}, record.specialData); - 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_* & *_view_ref keys from record context - const recordContext = { ...record.context }; - for (const key in recordContext) { - if (key.startsWith("default_") || key.endsWith("_view_ref")) { - delete recordContext[key]; - } - } - - var x2manyList = self._makeDataPoint({ - context: recordContext, - 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 && Array.isArray(commands[0]); - if (!isCommandList) { - commands = [[6, false, commands]]; - } - commands.forEach((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 - r.getFieldNames().forEach((fieldName) => { - r.data[fieldName] = null; - }); - - r._changes = Object.assign(r.data, value[2]); - 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 (['one2many', 'many2many'].includes(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 - value[2].forEach((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 - Object.keys(many2ones).forEach((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} - */ - _readMissingFields: function (list, resIDs, fieldNames, withoutRecordData) { - 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 = Object.assign({}, record.data, record._changes); - if (fieldNames.filter((x) => Object.keys(data).indexOf(x) < 0).length) { - missingIDs.push(resId); - } - } - - var def; - if (missingIDs.length && fieldNames.length) { - def = self._performRPC({ - context: list.getContext({ withoutRecordData: !!withoutRecordData }), - fieldNames: fieldNames, - ids: missingIDs, - method: 'read', - model: list.model, - }); - } else { - def = Promise.resolve(missingIDs.map((id) => { - return {id:id}; - })); - } - return def.then(function (records) { - resIDs.forEach((id) => { - var dataPoint; - var data = records.find((record) => record.id === id); - if (id in list._cache) { - dataPoint = self.localData[list._cache[id]]; - if (data) { - self._parseServerData(fieldNames, dataPoint, data); - Object.assign(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 - Object.values(list._changes || {}).forEach((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} 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 = unique(list.getFieldNames().concat(rawGroupBy)); - var orderedBy = list.orderedBy?.filter(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 = list.data.map((groupID) => self.localData[groupID]); - list.data = []; - list.count = 0; - var defs = []; - var openGroupCount = 0; - - groups.forEach((group) => { - var aggregateValues = {}; - for (const [key, value] of Object.entries(group)) { - if (fields.includes(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 = list.fields[rawGroupBy].selection.find(c => 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, - range: group.__range, - 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 = previousGroups.find(g => g.res_id === newGroup.res_id && g.value === newGroup.value); - if (oldGroup) { - delete self.localData[newGroup.id]; - // restore the internal state of the group - const oldPropsToKeep = self._getGroupedListPropsToKeep(oldGroup, { - onlyGroup: options.onlyGroups, - hasSubgroups: newGroup.groupedBy.length - }); - Object.assign(newGroup, oldPropsToKeep); - // 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 = Object.assign(options, {enableRelationalFetch: false}); - 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 = previousGroups.map((group) => group.id).filter((x) => list.data.indexOf(x)<0); - emptyGroupsIDs.forEach((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 = groups.map((group) => { - return group ? group.res_ids : []; - }).flat(); - } - 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} resolves to the fetched list object - */ - _readUngroupedList: function (list, options) { - options = options || {}; - 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 = list.orderedBy.map((list) => list.name); - def = this._readMissingFields(list, list.res_ids.filter(id => Number.isFinite(id)), fieldNames, options.withoutRecordData); - } - 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 (Number.isFinite(resId)) { - resIDs.push(resId); - } - } - return self._readMissingFields(list, resIDs, fieldNames, options.withoutRecordData).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} 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; - element._changes.forEach((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; - element.isM2MGrouped = element.groupedBy.some((group) => { - const [fieldName] = group.split(':'); - return element.fields[fieldName].type === "many2many"; - }); - } - 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 = element.res_ids.indexOf(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 (JSON.stringify(record._specialDataCache[fieldName]) === JSON.stringify(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 (alongside the groups by the - // call to 'web_read_group'), so we can bypass the search_read - // But the web_read_group returns the rawGroupBy field's value, which may not be present - // in the view. So we filter it out. - const fieldNameSet = new Set(fieldNames); - fieldNameSet.add("id"); // don't filter out the id - list.__data.records.forEach(record => - Object.keys(record) - .filter(fieldName => !fieldNameSet.has(fieldName)) - .forEach(fieldName => delete record[fieldName])); - prom = Promise.resolve(list.__data); - } else { - prom = this._rpc({ - route: '/web/dataset/search_read', - model: list.model, - fields: fieldNames, - context: Object.assign({}, list.getContext(), {bin_size: true}), - domain: this._getUngroupedListDomain(list), - 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 = result.records.map((r) => r.id); - var data = result.records.map((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 = []; - idsInRange.forEach((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 (Object.values(list._changes || {}).find((change) => change.isNew === true && change.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 = Object.assign({}, r1.data, r1._changes); - var data2 = Object.assign({}, 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 = parent.data.map((dataPointID) => self.localData[dataPointID].res_ids).flat(); - 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; - var id2Values = new Map(values.map((value) => [value.id, value])) - - records.forEach((record) => { - var x2mList = self.localData[record.data[fieldName]]; - x2mList.data = []; - x2mList.res_ids.forEach((res_id) => { - var dataPoint = self._makeDataPoint({ - modelName: field.relation, - data: id2Values.get(res_id), - fields: fields, - fieldsInfo: fieldsInfo, - parentID: x2mList.id, - viewType: viewType, - }); - x2mList.data.push(dataPoint.id); - x2mList._cache[res_id] = dataPoint.id; - }); - }); - }, - /** - * Used to apply a updateFn to other records with same res_id, that are - * under the same topmost list groupedby many2many. - * - * When a list datapoint is grouped by many2many, same res_id record could - * be in multiple groups, so use this method to apply the same callback to - * each one of them. - * - * @protected - * @param {string} recordID local resource - * @param {(recordID: string)=>void} updateFn called for each record datapoint - * representing the same res_id record - */ - _updateDuplicateRecords(recordID, updateFn) { - const { res_id } = this.localData[recordID]; - // Get the topmost groupedby m2m list - const getTopmostID = () => { - let element = this.localData[recordID]; - while (element.parentID && this.localData[element.parentID].type === "list") { - element = this.localData[element.parentID]; - } - return element.type === "list" && element.isM2MGrouped ? element.id : false; - }; - const topmostID = getTopmostID(); - if (!topmostID) { - return; - } - const topmostList = this.get(topmostID); - utils.traverse_records(topmostList, (r) => { - if (r.res_id === res_id && r.id !== recordID) { - updateFn(r.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 (['one2many', 'many2one', 'many2many'].includes(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); - element.data.forEach((elemId) => { - var elem = self.localData[elemId]; - self._visitChildren(elem, fn); - }); - } - }, -}); - -export default BasicModel; diff --git a/addons/web/static/src/legacy/js/views/field_manager_mixin.js b/addons/web/static/src/legacy/js/views/field_manager_mixin.js deleted file mode 100644 index f4113de6d38..00000000000 --- a/addons/web/static/src/legacy/js/views/field_manager_mixin.js +++ /dev/null @@ -1,165 +0,0 @@ -/** @odoo-module alias=web.FieldManagerMixin **/ - -/** - * The FieldManagerMixin is a mixin, designed to do the plumbing between field - * widgets and a basicmodel. Field widgets can be used outside of a view. In - * that case, someone needs to listen to events bubbling up from the widgets and - * calling the correct methods on the model. This is the field_manager's job. - */ - -import { pick } from '@web/core/utils/objects'; -import BasicModel from 'web.BasicModel'; -import concurrency from 'web.concurrency'; - -var FieldManagerMixin = { - custom_events: { - field_changed: '_onFieldChanged', - load: '_onLoad', - mutexify: '_onMutexify', - }, - /** - * A FieldManagerMixin can be initialized with an instance of a basicModel. - * If not, it will simply uses its own. - * - * @param {BasicModel} [model] - */ - init: function (model) { - this.model = model || new BasicModel(this); - this.mutex = new concurrency.Mutex(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Apply changes by notifying the basic model, then saving the data if - * necessary, and finally, confirming the changes to the UI. - * - * @todo find a way to remove ugly 3rd argument... - * - * @param {string} dataPointID - * @param {Object} changes - * @param {OdooEvent} event - * @returns {Promise} resolves when the change has been done, and the UI - * updated - */ - _applyChanges: function (dataPointID, changes, event) { - var self = this; - var options = pick(event.data, 'context', 'doNotSetDirty', 'notifyChange', 'viewType', 'allowWarning'); - return this.model.notifyChanges(dataPointID, changes, options) - .then(function (result) { - if (event.data.force_save) { - return self.model.save(dataPointID).then(function () { - return self._confirmSave(dataPointID); - }).guardedCatch(function () { - return self._rejectSave(dataPointID); - }); - } else if (options.notifyChange !== false) { - return self._confirmChange(dataPointID, result, event); - } - }); - }, - /** - * This method will be called whenever a field value has changed (and has - * been confirmed by the model). - * - * @abstract - * @param {string} id basicModel Id for the changed record - * @param {string[]} fields the fields (names) that have been changed - * @param {OdooEvent} event the event that triggered the change - * @returns {Promise} - */ - _confirmChange: function (id, fields, event) { - return Promise.resolve(); - }, - /** - * This method will be called whenever a save has been triggered by a change - * in some controlled field value. For example, when a priority widget is - * being changed in a readonly form. - * - * @see _onFieldChanged - * @abstract - * @param {string} id The basicModel ID for the saved record - * @returns {Promise} - */ - _confirmSave: function (id) { - return Promise.resolve(); - }, - /** - * This method will be called whenever a save has been triggered by a change - * and has failed. For example, when a statusbar button is clicked in a - * readonly form view. - * - * @abstract - * @private - * @param {string} id The basicModel ID for the saved record - * @returns {Deferred} - */ - _rejectSave: function (id) { - return Promise.resolve(); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * This is the main job of the FMM: deciding what to do when a controlled - * field changes. Most of the time, it notifies the model that a change - * just occurred, then confirm the change. - * - * @param {OdooEvent} event - */ - _onFieldChanged: function (event) { - // in case of field changed in relational record (e.g. in the form view - // of a one2many subrecord), the field_changed event must be stopped as - // soon as is it handled by a field_manager (i.e. the one of the - // subrecord's form view), otherwise it bubbles up to the main form view - // but its model doesn't have any data related to the given dataPointID - event.stopPropagation(); - return this._applyChanges(event.data.dataPointID, event.data.changes, event) - .then(event.data.onSuccess || function () {}) - .guardedCatch(event.data.onFailure || function () {}); - }, - /** - * Some widgets need to trigger a reload of their data. For example, a - * one2many with a pager needs to be able to fetch the next page. To do - * that, it can trigger a load event. This will then ask the model to - * actually reload the data, then call the on_success callback. - * - * @param {OdooEvent} event - * @param {number} [event.data.limit] - * @param {number} [event.data.offset] - * @param {function} [event.data.on_success] callback - */ - _onLoad: function (event) { - var self = this; - event.stopPropagation(); // prevent other field managers from handling this request - var data = event.data; - if (!data.on_success) { return; } - var params = {}; - if ('limit' in data) { - params.limit = data.limit; - } - if ('offset' in data) { - params.offset = data.offset; - } - this.mutex.exec(function () { - return self.model.reload(data.id, params).then(function (db_id) { - data.on_success(self.model.get(db_id)); - }); - }); - }, - /** - * @private - * @param {OdooEvent} ev - * @param {function} ev.data.action the function to execute in the mutex - */ - _onMutexify: function (ev) { - ev.stopPropagation(); // prevent other field managers from handling this request - this.mutex.exec(ev.data.action); - }, -}; - -export default FieldManagerMixin; diff --git a/addons/web/static/src/legacy/js/views/standalone_field_manager_mixin.js b/addons/web/static/src/legacy/js/views/standalone_field_manager_mixin.js deleted file mode 100644 index cef759851c4..00000000000 --- a/addons/web/static/src/legacy/js/views/standalone_field_manager_mixin.js +++ /dev/null @@ -1,60 +0,0 @@ -/** @odoo-module alias=web.StandaloneFieldManagerMixin **/ - -import FieldManagerMixin from 'web.FieldManagerMixin'; - -/** - * The StandaloneFieldManagerMixin is a mixin, designed to be used by a widget - * that instanciates its own field widgets. - * - * @mixin - * @name StandaloneFieldManagerMixin - * @mixes FieldManagerMixin - * @property {Function} _confirmChange - * @property {Function} _registerWidget - */ -var StandaloneFieldManagerMixin = Object.assign({}, FieldManagerMixin, { - - /** - * @override - */ - init: function () { - FieldManagerMixin.init.apply(this, arguments); - - // registeredWidgets is a dict of all field widgets used by the widget - this.registeredWidgets = {}; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * This method will be called whenever a field value has changed (and has - * been confirmed by the model). - * - * @private - * @param {string} id basicModel Id for the changed record - * @param {string[]} fields the fields (names) that have been changed - * @param {OdooEvent} event the event that triggered the change - * @returns {Promise} - */ - _confirmChange: function (id, fields, event) { - var result = FieldManagerMixin._confirmChange.apply(this, arguments); - var record = this.model.get(id); - for (const [fieldName, widget] of Object.entries(this.registeredWidgets[id])) { - if (fields.includes(fieldName)) { - widget.reset(record, event); - } - } - return result; - }, - - _registerWidget: function (datapointID, fieldName, widget) { - if (!this.registeredWidgets[datapointID]) { - this.registeredWidgets[datapointID] = {}; - } - this.registeredWidgets[datapointID][fieldName] = widget; - }, -}); - -export default StandaloneFieldManagerMixin; diff --git a/addons/web/static/src/legacy/js/views/view_utils.js b/addons/web/static/src/legacy/js/views/view_utils.js deleted file mode 100644 index dcc56412de2..00000000000 --- a/addons/web/static/src/legacy/js/views/view_utils.js +++ /dev/null @@ -1,112 +0,0 @@ -/** @odoo-module alias=web.viewUtils **/ - - -import { unique } from '@web/core/utils/arrays'; -import { omit } from '@web/core/utils/objects'; -import * as dom from 'web.dom'; -import * as utils from 'web.utils'; - -var viewUtils = { - /** - * Since a date field can have a granularity in a groupby (date_field:granularity), - * when we require only the field name, we have to split out the - * eventual granularity - * - * @param {string} groupedBy the groupby with the eventual granularity - * @returns {string} - */ - getGroupByField: function(groupedBy) { - return groupedBy && groupedBy.split(':')[0]; - }, - /** - * Returns the value of a group dataPoint, i.e. the value of the groupBy - * field for the records in that group. - * - * @param {Object} group dataPoint of type list, corresponding to a group - * @param {string} groupedBy the value of the groupby, i.e. - * field_name:granularity for date/datetime - * @returns {string | integer | false} - */ - getGroupValue: function (group, groupedBy) { - var groupedByField = group.fields[this.getGroupByField(groupedBy)]; - switch (groupedByField.type) { - case 'many2one': - return group.res_id || false; - case 'selection': - var descriptor = groupedByField.selection.find(option => option[1] === group.value); - return descriptor && descriptor[0]; - case 'char': - case 'boolean': - return group.value; - // for a date/datetime field, we take the last moment of the group as the group value - case 'date': - case 'datetime': - const [format, granularity] = groupedByField.type === 'date' ? - ["YYYY-MM-DD", 'day'] : ["YYYY-MM-DD HH:mm:ss", 'second']; - return group.range[groupedBy] ? - moment.utc(group.range[groupedBy].to).subtract(1, granularity).format(format) : false; - default: - return false; // other field types are not handled - } - }, - /** - * States whether or not the quick create feature is available for the given - * datapoint, depending on its groupBy field. - * - * @param {Object} list dataPoint of type list - * @returns {Boolean} true iff the kanban quick create feature is available - */ - isQuickCreateEnabled: function (list) { - var groupByField = list.groupedBy[0] && viewUtils.getGroupByField(list.groupedBy[0]); - if (!groupByField) { - return false; - } - var dateTypes = ['date', 'datetime']; - if (!list.fields[groupByField].readonly && - dateTypes.includes(list.fields[groupByField].type)) { - return list.fieldsInfo && list.fieldsInfo[list.viewType][groupByField] && - list.fieldsInfo[list.viewType][groupByField].allowGroupRangeValue; - } - var availableTypes = ['char', 'boolean', 'many2one', 'selection']; - return availableTypes.includes(list.fields[groupByField].type); - }, - /** - * @param {string} arch view arch - * @returns {Object} parsed arch - */ - parseArch: function (arch) { - var doc = $.parseXML(arch).documentElement; - var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban'; - return utils.xml_to_json(doc, stripWhitespaces); - }, - /** - * Renders a button according to a given arch node element. - * - * @param {Object} node - * @param {Object} [options] - * @param {string} [options.extraClass] - * @param {boolean} [options.textAsTitle=false] - * @returns {jQuery} - */ - renderButtonFromNode: function (node, options) { - var btnOptions = { - attrs: omit(node.attrs, 'icon', 'string', 'type', 'attrs', 'modifiers', 'options', 'effect'), - icon: node.attrs.icon, - }; - if (options && options.extraClass) { - var classes = btnOptions.attrs.class ? btnOptions.attrs.class.split(' ') : []; - btnOptions.attrs.class = unique(classes.concat(options.extraClass.split(' '))).join(' '); - } - var str = (node.attrs.string || '').replace(/_/g, ''); - if (str) { - if (options && options.textAsTitle) { - btnOptions.attrs.title = str; - } else { - btnOptions.text = str; - } - } - return dom.renderButton(btnOptions); - }, -}; - -export default viewUtils; diff --git a/addons/web/static/src/views/model.js b/addons/web/static/src/model/model.js similarity index 69% rename from addons/web/static/src/views/model.js rename to addons/web/static/src/model/model.js index 40d95d2c25d..c1945e98423 100644 --- a/addons/web/static/src/views/model.js +++ b/addons/web/static/src/model/model.js @@ -2,8 +2,8 @@ import { useBus, useService } from "@web/core/utils/hooks"; import { SEARCH_KEYS } from "@web/search/with_search/with_search"; -import { buildSampleORM } from "@web/views/sample_server"; import { useSetupView } from "@web/views/view_hook"; +import { buildSampleORM } from "./sample_server"; import { EventBus, onWillStart, onWillUpdateProps, useComponent } from "@odoo/owl"; @@ -20,7 +20,6 @@ export class Model { this.env = env; this.orm = services.orm; this.bus = new EventBus(); - this.useSampleModel = false; // will be set to true by the "useModel" hook if necessary this.setup(params, services); } @@ -81,12 +80,36 @@ function getSearchParams(props) { * @param {T} ModelClass * @param {Object} params * @param {Object} [options] - * @param {Function} [options.onUpdate] - * @param {Function} [options.onWillStart] callback executed before the first load of the model - * @param {boolean} [options.ignoreUseSampleModel] + * @param {Function} [options.beforeFirstLoad] * @returns {InstanceType} */ export function useModel(ModelClass, params, options = {}) { + const component = useComponent(); + const services = {}; + for (const key of ModelClass.services) { + services[key] = useService(key); + } + services.orm = services.orm || useService("orm"); + const model = new ModelClass(component.env, params, services); + onWillStart(async () => { + await options.beforeFirstLoad?.(); + return model.load(component.props); + }); + onWillUpdateProps((nextProps) => model.load(nextProps)); + return model; +} + +/** + * @template {typeof Model} T + * @param {T} ModelClass + * @param {Object} params + * @param {Object} [options] + * @param {Function} [options.onUpdate] + * @param {Function} [options.onWillStart] + * @param {Function} [options.onWillStartAfterLoad] + * @returns {InstanceType} + */ +export function useModelWithSampleData(ModelClass, params, options = {}) { const component = useComponent(); if (!(ModelClass.prototype instanceof Model)) { throw new Error(`the model class should extend Model`); @@ -98,6 +121,7 @@ export function useModel(ModelClass, params, options = {}) { services.orm = services.orm || useService("orm"); const model = new ModelClass(component.env, params, services); + useBus( model.bus, "update", @@ -108,33 +132,31 @@ export function useModel(ModelClass, params, options = {}) { ); const globalState = component.props.globalState || {}; + const localState = component.props.state || {}; let useSampleModel = Boolean( "useSampleModel" in globalState ? globalState.useSampleModel : component.props.useSampleModel ); - model.useSampleModel = !options.ignoreUseSampleModel ? useSampleModel : false; + model.useSampleModel = useSampleModel; const orm = model.orm; - let sampleORM = globalState.sampleORM; + let sampleORM = localState.sampleORM; const user = useService("user"); let started = false; + async function load(props) { const searchParams = getSearchParams(props); await model.load(searchParams); - if (!options.ignoreUseSampleModel) { - if (useSampleModel && !model.hasData()) { - sampleORM = - sampleORM || - buildSampleORM(component.props.resModel, component.props.fields, user); - sampleORM.setGroups(model.getGroups()); - // Load data with sampleORM then restore real ORM. - model.orm = sampleORM; - await model.load(searchParams); - model.orm = orm; - } else { - useSampleModel = false; - model.useSampleModel = useSampleModel; - } + if (useSampleModel && !model.hasData()) { + sampleORM = + sampleORM || buildSampleORM(component.props.resModel, component.props.fields, user); + // Load data with sampleORM then restore real ORM. + model.orm = sampleORM; + await model.load(searchParams); + model.orm = orm; + } else { + useSampleModel = false; + model.useSampleModel = useSampleModel; } if (started) { model.notify(); @@ -151,15 +173,16 @@ export function useModel(ModelClass, params, options = {}) { started = true; }); onWillUpdateProps((nextProps) => { - if (!options.ignoreUseSampleModel) { - useSampleModel = false; - } + useSampleModel = false; load(nextProps); }); useSetupView({ getGlobalState() { - return { sampleORM, useSampleModel }; + return { useSampleModel }; + }, + getLocalState: () => { + return { sampleORM }; }, }); diff --git a/addons/web/static/src/model/relational_model/datapoint.js b/addons/web/static/src/model/relational_model/datapoint.js new file mode 100644 index 00000000000..9c845695f6f --- /dev/null +++ b/addons/web/static/src/model/relational_model/datapoint.js @@ -0,0 +1,92 @@ +/* @odoo-module */ + +import { evalDomain } from "@web/core/domain"; +import { Reactive } from "@web/core/utils/reactive"; +import { getId } from "./utils"; + +/** + * @typedef Params + * @property {string} resModel + * @property {Object} context + * @property {{[key: string]: FieldInfo}} activeFields + * @property {{[key: string]: Field}} fields + */ + +/** + * @typedef Field + * @property {string} name + * @property {string} type + * @property {[string,string][]} [selection] + */ + +/** + * @typedef FieldInfo + * @property {string} context + * @property {boolean} invisible + * @property {boolean} readonly + * @property {boolean} required + * @property {boolean} onChange + */ + +export class DataPoint extends Reactive { + /** + * @param {import("./relational_model").RelationalModel} model + * @param {import("./relational_model").Config"} config + * @param {any} data + * @param {Object} [options] + */ + constructor(model, config, data, options) { + super(...arguments); + this.id = getId("datapoint"); + this.model = model; + this._config = config; + this.setup(config, data, options); + } + + /** + * @abstract + * @param {Object} params + * @param {Object} state + */ + setup() {} + + get activeFields() { + return this.config.activeFields; + } + + get fields() { + return this.config.fields; + } + + get fieldNames() { + return Object.keys(this.activeFields).filter( + (fieldName) => !this.fields[fieldName].relatedPropertyField + ); + } + + get resModel() { + return this.config.resModel; + } + + get config() { + return this._config; + } + + get context() { + return this.config.context; + } + + // ------------------------------------------------------------------------- + // Public + // ------------------------------------------------------------------------- + + /** + * @param {string} fieldName + * @returns {boolean} + */ + isFieldReadonly(fieldName) { + const activeField = this.activeFields[fieldName]; + const { readonly } = activeField || this.fields[fieldName]; + return readonly && evalDomain(readonly, this.evalContext); + } +} diff --git a/addons/web/static/src/model/relational_model/dynamic_group_list.js b/addons/web/static/src/model/relational_model/dynamic_group_list.js new file mode 100644 index 00000000000..34ba5cda159 --- /dev/null +++ b/addons/web/static/src/model/relational_model/dynamic_group_list.js @@ -0,0 +1,291 @@ +/* @odoo-module */ +//@ts-check + +import { Domain } from "@web/core/domain"; +import { DynamicList } from "./dynamic_list"; + +export class DynamicGroupList extends DynamicList { + static type = "DynamicGroupList"; + + /** + * @param {import("./relational_model").Config} config + * @param {Object} data + */ + setup(config, data) { + super.setup(...arguments); + this.isGrouped = true; + /** @type {import("./group").Group[]} */ + this.groups = data.groups.map((g) => this._createGroupDatapoint(g)); + this.count = data.length; + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + get groupBy() { + return this.config.groupBy; + } + + get groupByField() { + return this.fields[this.groupBy[0].split(":")[0]]; + } + + get hasData() { + if (this.count === 0) { + return false; + } + return this.groups.some((group) => group.hasData); + } + + /** + * List of loaded records inside groups. + * @returns {import("./record").Record[]} + */ + get records() { + return this.groups + .filter((group) => !group.isFolded) + .map((group) => group.records) + .flat(); + } + + /** + * @returns {number} + */ + get recordCount() { + return this.groups.reduce((acc, group) => acc + group.count, 0); + } + + // ------------------------------------------------------------------------- + // Public + // ------------------------------------------------------------------------- + + async createGroup(groupName, groupData, isFolded) { + if (!this.groupByField || this.groupByField.type !== "many2one") { + throw new Error("Cannot create a group on a non many2one group field"); + } + + await this.model.mutex.exec(() => this._createGroup(groupName, groupData, isFolded)); + } + + async deleteGroups(groups) { + await this.model.mutex.exec(() => this._deleteGroups(groups)); + } + + /** + * @param {string} dataRecordId + * @param {string} dataGroupId + * @param {string} refId + * @param {string} targetGroupId + */ + async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) { + const targetGroup = this.groups.find((g) => g.id === targetGroupId); + if (dataGroupId === targetGroupId) { + // move a record inside the same group + targetGroup.list.records = await targetGroup.list._resequence( + targetGroup.list.records, + this.resModel, + dataRecordId, + refId + ); + return; + } + + // move record from a group to another group + const sourceGroup = this.groups.find((g) => g.id === dataGroupId); + const recordIndex = sourceGroup.list.records.findIndex((r) => r.id === dataRecordId); + const record = sourceGroup.list.records[recordIndex]; + // step 1: move record to correct position + const refIndex = targetGroup.list.records.findIndex((r) => r.id === refId); + const oldIndex = sourceGroup.list.records.findIndex((r) => r.id === dataRecordId); + sourceGroup._removeRecords([record.id]); + targetGroup._addRecord(record, refIndex + 1); + // step 2: update record value + const value = + targetGroup.groupByField.type === "many2one" + ? [targetGroup.value, targetGroup.displayName] + : targetGroup.value; + const revert = () => { + targetGroup._removeRecords([record.id]); + sourceGroup._addRecord(record, oldIndex); + }; + try { + await record.update({ [targetGroup.groupByField.name]: value }); + const res = await record.save({ noReload: true }); + if (!res) { + revert(); + } + } catch (e) { + // revert changes + revert(); + throw e; + } + if (!targetGroup.isFolded) { + targetGroup.list.records = await targetGroup.list._resequence( + targetGroup.list.records, + this.resModel, + dataRecordId, + refId + ); + } + } + + async resequence(movedGroupId, targetGroupId) { + if (!this.groupByField || this.groupByField.type !== "many2one") { + throw new Error("Cannot resequence a group on a non many2one group field"); + } + + return this.model.mutex.exec(async () => { + this.groups = await this._resequence( + this.groups, + this.groupByField.relation, + movedGroupId, + targetGroupId + ); + }); + } + + async sortBy(fieldName) { + if (!this.groups.length) { + return; + } + if (this.groups.every((group) => group.isFolded)) { + // all groups are folded + if (this.groupByField.name !== fieldName) { + // grouped by another field than fieldName + if (!(fieldName in this.groups[0].aggregates)) { + // fieldName has no aggregate values + return; + } + } + } + return super.sortBy(fieldName); + } + + // ------------------------------------------------------------------------- + // Protected + // ------------------------------------------------------------------------- + + async _createGroup(groupName, groupData = {}, isFolded = false) { + groupData = { ...groupData, name: groupName }; + const [id] = await this.model.orm.create(this.groupByField.relation, [groupData], { + context: this.context, + }); + const lastGroup = this.groups.at(-1); + + // This is almost a copy/past of the code in relational_model.js + // Maybe we can create an addGroup method in relational_model.js + // and call it from here and from relational_model.js + const commonConfig = { + resModel: this.config.resModel, + fields: this.config.fields, + activeFields: this.config.activeFields, + }; + const context = { + ...this.context, + [`default_${this.groupByField.name}`]: id, + }; + const nextConfigGroups = { ...this.config.groups }; + const domain = Domain.and([this.domain, [[this.groupByField.name, "=", id]]]).toList(); + nextConfigGroups[id] = { + ...commonConfig, + context, + groupByFieldName: this.groupByField.name, + isFolded, + initialDomain: domain, + list: { + ...commonConfig, + context, + domain: domain, + groupBy: [], + orderBy: this.orderBy, + }, + }; + this.model._updateConfig(this.config, { groups: nextConfigGroups }, { noReload: true }); + + const data = { + count: 0, + length: 0, + records: [], + __domain: domain, + [this.groupByField.name]: [id, groupName], + value: id, + displayName: groupName, + }; + + const group = this._createGroupDatapoint(data); + if (lastGroup) { + const groups = [...this.groups, group]; + this.groups = await this._resequence( + groups, + this.groupByField.relation, + group.id, + lastGroup.id + ); + } else { + this.groups.push(group); + } + } + + _createGroupDatapoint(data) { + return new this.model.constructor.Group(this.model, this.config.groups[data.value], data); + } + + async _deleteGroups(groups) { + const shouldReload = groups.some((g) => g.count > 0); + await this._unlinkGroups(groups); + if (shouldReload) { + const configGroups = { ...this.config.groups }; + for (const group of groups) { + delete configGroups[group.value]; + } + const response = await this.model._updateConfig(this.config, { groups: configGroups }); + this.groups = response.groups.map((group) => this._createGroupDatapoint(group)); + this.count = response.length; + } else { + for (const group of groups) { + this._removeGroup(group); + } + } + } + + _getDPresId(group) { + return group.value; + } + + _getDPHandleField(group) { + return group[this.handleField]; + } + + async _load(offset, limit, orderBy, domain) { + const response = await this.model._updateConfig(this.config, { + offset, + limit, + orderBy, + domain, + }); + this.groups = response.groups.map((group) => this._createGroupDatapoint(group)); + this.count = response.length; + } + + _removeGroup(group) { + const index = this.groups.findIndex((g) => g.id === group.id); + this.groups.splice(index, 1); + this.count--; + } + + _removeRecords(recordIds) { + const proms = []; + for (const group of this.groups) { + proms.push(group._removeRecords(recordIds)); + } + return Promise.all(proms); + } + + _unlinkGroups(groups) { + const groupResIds = groups.map((g) => g.value); + return this.model.orm.unlink(this.groupByField.relation, groupResIds, { + context: this.context, + }); + } +} diff --git a/addons/web/static/src/model/relational_model/dynamic_list.js b/addons/web/static/src/model/relational_model/dynamic_list.js new file mode 100644 index 00000000000..e8e2815a8c9 --- /dev/null +++ b/addons/web/static/src/model/relational_model/dynamic_list.js @@ -0,0 +1,388 @@ +/* @odoo-module */ + +import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { _t } from "@web/core/l10n/translation"; +import { sprintf } from "@web/core/utils/strings"; +import { DataPoint } from "./datapoint"; +import { Record } from "./record"; + +const DEFAULT_HANDLE_FIELD = "sequence"; + +export class DynamicList extends DataPoint { + /** + * @param {import("./relational_model").Config} config + */ + setup(config) { + super.setup(...arguments); + this.handleField = Object.keys(this.activeFields).find( + (fieldName) => this.activeFields[fieldName].isHandle + ); + if (!this.handleField && DEFAULT_HANDLE_FIELD in this.fields) { + this.handleField = DEFAULT_HANDLE_FIELD; + } + this.isDomainSelected = false; + this.evalContext = this.context; + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + get groupBy() { + return []; + } + + get orderBy() { + return this.config.orderBy; + } + + get domain() { + return this.config.domain; + } + + get editedRecord() { + return this.records.find((record) => record.isInEdition); + } + + get limit() { + return this.config.limit; + } + + get offset() { + return this.config.offset; + } + + get selection() { + return this.records.filter((record) => record.selected); + } + + // ------------------------------------------------------------------------- + // Public + // ------------------------------------------------------------------------- + + archive(isSelected) { + return this.model.mutex.exec(() => this._toggleArchive(isSelected, true)); + } + + canResequence() { + return !!this.handleField; + } + + deleteRecords(records = []) { + return this.model.mutex.exec(() => this._deleteRecords(records)); + } + + async enterEditMode(record) { + if (this.editedRecord === record) { + return true; + } + const canProceed = await this.leaveEditMode(); + if (canProceed) { + this.model._updateConfig(record.config, { mode: "edit" }, { noReload: true }); + } + return canProceed; + } + + /** + * @param {boolean} [isSelected] + * @returns {Promise} + */ + async getResIds(isSelected) { + let resIds; + if (isSelected) { + if (this.isDomainSelected) { + resIds = await this.model.orm.search(this.resModel, this.domain, { + limit: this.model.activeIdsLimit, + context: this.context, + }); + } else { + resIds = this.selection.map((r) => r.resId); + } + } else { + resIds = this.records.map((r) => r.resId); + } + return resIds; + } + + async leaveEditMode({ discard } = {}) { + if (this.editedRecord) { + let canProceed = true; + if (discard) { + await this.editedRecord.discard(); + if (this.editedRecord && this.editedRecord.isNew) { + this._removeRecords([this.editedRecord.id]); + } + } else { + if (!this.model._urgentSave) { + await this.editedRecord.checkValidity(); + if (!this.editedRecord) { + return true; + } + } + if (this.editedRecord.isNew && !this.editedRecord.dirty) { + this._removeRecords([this.editedRecord.id]); + } else { + canProceed = await this.editedRecord.save(); + } + } + + if (canProceed && this.editedRecord) { + this.model._updateConfig( + this.editedRecord.config, + { mode: "readonly" }, + { noReload: true } + ); + } else { + return canProceed; + } + } + return true; + } + + load(params = {}) { + const limit = params.limit === undefined ? this.limit : params.limit; + const offset = params.offset === undefined ? this.offset : params.offset; + const orderBy = params.orderBy === undefined ? this.orderBy : params.orderBy; + const domain = params.domain === undefined ? this.domain : params.domain; + return this.model.mutex.exec(() => this._load(offset, limit, orderBy, domain)); + } + + async multiSave(record) { + return this.model.mutex.exec(() => this._multiSave(record)); + } + + selectDomain(value) { + return this.model.mutex.exec(() => { + this.isDomainSelected = value; + }); + } + + sortBy(fieldName) { + return this.model.mutex.exec(() => { + let orderBy = [...this.orderBy]; + if (orderBy.length && orderBy[0].name === fieldName) { + orderBy[0] = { name: orderBy[0].name, asc: !orderBy[0].asc }; + } else { + orderBy = orderBy.filter((o) => o.name !== fieldName); + orderBy.unshift({ + name: fieldName, + asc: true, + }); + } + return this._load(this.offset, this.limit, orderBy, this.domain); + }); + } + + unarchive(isSelected) { + return this.model.mutex.exec(() => this._toggleArchive(isSelected, false)); + } + + // ------------------------------------------------------------------------- + // Protected + // ------------------------------------------------------------------------- + + async _deleteRecords(records) { + let resIds; + if (records.length) { + resIds = records.map((r) => r.resId); + } else { + resIds = await this.getResIds(true); + records = this.records.filter((r) => resIds.includes(r.resId)); + } + const unlinked = await this.model.orm.unlink(this.resModel, resIds, { + context: this.context, + }); + if (!unlinked) { + return false; + } + if ( + this.isDomainSelected && + resIds.length === this.model.activeIdsLimit && + resIds.length < this.count + ) { + const msg = sprintf( + _t(`Only the first %s records have been deleted (out of %s selected)`), + resIds.length, + this.count + ); + this.model.notification.add(msg, { title: _t("Warning") }); + } + await this._removeRecords(records.map((r) => r.id)); + return unlinked; + } + + _leaveSampleMode() { + if (this.model.useSampleModel) { + this.model.useSampleModel = false; + return this._load(this.offset, this.limit, this.orderBy, this.domain); + } + } + + async _multiSave(record) { + const changes = record._getChanges(); + if (!Object.keys(changes).length) { + return; + } + const validSelection = this.selection.filter((record) => { + return Object.keys(changes).every((fieldName) => { + if (record._isReadonly(fieldName)) { + return false; + } else if (record._isRequired(fieldName) && !changes[fieldName]) { + return false; + } + return true; + }); + }); + const canProceed = await this.model.hooks.onWillSaveMulti(record, changes, validSelection); + if (canProceed === false) { + return false; + } + if (validSelection.length === 0) { + this.model.dialog.add(AlertDialog, { + body: _t("No valid record to save"), + confirm: () => this.leaveEditMode({ discard: true }), + }); + return false; + } else { + const resIds = validSelection.map((r) => r.resId); + const context = this.context; + try { + await this.model.orm.write(this.resModel, resIds, changes, { context }); + } catch (e) { + record._discard(); + this.model._updateConfig(record.config, { mode: "readonly" }, { noReload: true }); + throw e; + } + const records = await this.model._loadRecords({ ...this.config, resIds }); + for (const record of validSelection) { + const serverValues = records.find((r) => r.id === record.resId); + record._applyValues(serverValues); + this.model._updateSimilarRecords(record, serverValues); + } + record._discard(); + this.model._updateConfig(record.config, { mode: "readonly" }, { noReload: true }); + } + this.model.hooks.onSavedMulti(validSelection); + return true; + } + + async _resequence(originalList, resModel, movedId, targetId) { + if (this.resModel === resModel && !this.canResequence()) { + return originalList; + } + const handleField = this.handleField; + const dataPoints = [...originalList]; + const order = this.orderBy.find((o) => o.name === handleField); + const asc = !order || order.asc; + + // Find indices + const fromIndex = dataPoints.findIndex((d) => d.id === movedId); + let toIndex = 0; + if (targetId !== null) { + const targetIndex = dataPoints.findIndex((d) => d.id === targetId); + toIndex = fromIndex > targetIndex ? targetIndex + 1 : targetIndex; + } + + const getSequence = (dp) => dp && this._getDPHandleField(dp, handleField); + + // Determine which records/groups need to be modified + const firstIndex = Math.min(fromIndex, toIndex); + const lastIndex = Math.max(fromIndex, toIndex) + 1; + let reorderAll = dataPoints.some( + (dp) => this._getDPHandleField(dp, handleField) === undefined + ); + if (!reorderAll) { + let lastSequence = (asc ? -1 : 1) * Infinity; + for (let index = 0; index < dataPoints.length; index++) { + const sequence = getSequence(dataPoints[index]); + if ( + ((index < firstIndex || index >= lastIndex) && + ((asc && lastSequence >= sequence) || + (!asc && lastSequence <= sequence))) || + (index >= firstIndex && index < lastIndex && lastSequence === sequence) + ) { + reorderAll = true; + } + lastSequence = sequence; + } + } + + // Perform the resequence in the list of records/groups + const [dp] = dataPoints.splice(fromIndex, 1); + dataPoints.splice(toIndex, 0, dp); + + // Creates the list of records/groups to modify + let toReorder = dataPoints; + if (!reorderAll) { + toReorder = toReorder.slice(firstIndex, lastIndex).filter((r) => r.id !== movedId); + if (fromIndex < toIndex) { + toReorder.push(dp); + } else { + toReorder.unshift(dp); + } + } + if (!asc) { + toReorder.reverse(); + } + + const resIds = toReorder.map((d) => this._getDPresId(d)).filter((id) => id && !isNaN(id)); + const sequences = toReorder.map(getSequence); + const offset = sequences.length && Math.min(...sequences); + + // Try to write new sequences on the affected records/groups + const params = { + model: resModel, + ids: resIds, + context: this.context, + field: handleField, + }; + if (offset) { + params.offset = offset; + } + const wasResequenced = await this.model.rpc("/web/dataset/resequence", params); + if (!wasResequenced) { + return originalList; + } + + // Read the actual values set by the server and update the records/groups + const kwargs = { context: this.context }; + const result = await this.model.orm.read(resModel, resIds, [handleField], kwargs); + for (const dpData of result) { + const dp = dataPoints.find((d) => this._getDPresId(d) === dpData.id); + if (dp instanceof Record) { + dp._applyValues(dpData); + } else { + dp[handleField] = dpData[handleField]; + } + } + + return dataPoints; + } + + async _toggleArchive(isSelected, state) { + const method = state ? "action_archive" : "action_unarchive"; + const context = this.context; + const resIds = await this.getResIds(isSelected); + const action = await this.model.orm.call(this.resModel, method, [resIds], { context }); + if ( + this.isDomainSelected && + resIds.length === this.model.activeIdsLimit && + resIds.length < this.count + ) { + const msg = sprintf( + _t("Of the %s records selected, only the first %s have been archived/unarchived."), + resIds.length, + this.count + ); + this.model.notification.add(msg, { title: _t("Warning") }); + } + const reload = () => this.model.load(); + if (action && Object.keys(action).length) { + this.model.action.doAction(action, { + onClose: reload, + }); + } else { + return reload(); + } + } +} diff --git a/addons/web/static/src/model/relational_model/dynamic_record_list.js b/addons/web/static/src/model/relational_model/dynamic_record_list.js new file mode 100644 index 00000000000..a23111bdbb0 --- /dev/null +++ b/addons/web/static/src/model/relational_model/dynamic_record_list.js @@ -0,0 +1,184 @@ +/* @odoo-module */ + +import { DynamicList } from "./dynamic_list"; + +export class DynamicRecordList extends DynamicList { + static type = "DynamicRecordList"; + + /** + * @param {import("./relational_model").Config} config + * @param {Object} data + */ + setup(config, data) { + super.setup(config); + /** @type {import("./record").Record[]} */ + this.records = data.records.map((r) => this._createRecordDatapoint(r)); + this._updateCount(data); + } + + // ------------------------------------------------------------------------- + // Getter + // ------------------------------------------------------------------------- + + get hasData() { + return this.count > 0; + } + + // ------------------------------------------------------------------------- + // Public + // ------------------------------------------------------------------------- + + /** + * @param {number} resId + * @param {boolean} [atFirstPosition] + * @returns {Promise} the newly created record + */ + addExistingRecord(resId, atFirstPosition) { + return this.model.mutex.exec(async () => { + const record = this._createRecordDatapoint({}); + await record._load({ resId }); + this._addRecord(record, atFirstPosition ? 0 : this.records.length); + return record; + }); + } + + /** + * @param {boolean} [atFirstPosition=false] + * @returns {Promise} + */ + addNewRecord(atFirstPosition = false) { + return this.model.mutex.exec(async () => { + await this._leaveSampleMode(); + return this._addNewRecord(atFirstPosition); + }); + } + + /** + * Performs a search_count with the current domain to set the count. This is + * useful as web_search_read limits the count for performance reasons, so it + * might sometimes be less than the real number of records matching the domain. + **/ + async fetchCount() { + this.count = await this.model._updateCount(this.config); + this.hasLimitedCount = false; + return this.count; + } + + moveRecord(dataRecordId, _dataGroupId, refId, _targetGroupId) { + return this.resequence(dataRecordId, refId); + } + + removeRecord(record) { + if (!record.isNew) { + throw new Error("removeRecord can't be called on an existing record"); + } + const index = this.records.findIndex((r) => r === record); + if (index < 0) { + return; + } + this.records.splice(index, 1); + this.count--; + return record; + } + + async resequence(movedRecordId, targetRecordId) { + return this.model.mutex.exec( + async () => + (this.records = await this._resequence( + this.records, + this.resModel, + movedRecordId, + targetRecordId + )) + ); + } + + // ------------------------------------------------------------------------- + // Protected + // ------------------------------------------------------------------------- + + async _addNewRecord(atFirstPosition) { + const values = await this.model._loadNewRecord({ + resModel: this.resModel, + activeFields: this.activeFields, + fields: this.fields, + context: this.context, + }); + const record = this._createRecordDatapoint(values, "edit"); + this._addRecord(record, atFirstPosition ? 0 : this.records.length); + return record; + } + + _addRecord(record, index) { + this.records.splice(Number.isInteger(index) ? index : this.records.length, 0, record); + this.count++; + } + + _createRecordDatapoint(data, mode = "readonly") { + return new this.model.constructor.Record( + this.model, + { + context: this.context, + activeFields: this.activeFields, + resModel: this.resModel, + fields: this.fields, + resId: data.id || false, + resIds: data.id ? [data.id] : [], + isMonoRecord: true, + mode, + }, + data, + { manuallyAdded: !data.id } + ); + } + + _getDPresId(record) { + return record.resId; + } + + _getDPHandleField(record) { + return record.data[this.handleField]; + } + + async _load(offset, limit, orderBy, domain) { + const response = await this.model._updateConfig(this.config, { + offset, + limit, + orderBy, + domain, + }); + this.records = response.records.map((r) => this._createRecordDatapoint(r)); + this._updateCount(response); + } + + _removeRecords(recordIds) { + const _records = this.records.filter((r) => !recordIds.includes(r.id)); + if (this.offset && !_records.length) { + // we weren't on the first page, and we removed all records of the current page + const offset = Math.max(this.offset - this.limit, 0); + return this._load(offset, this.limit, this.orderBy, this.domain); + } + const nbRemovedRecords = this.records.length - _records.length; + if (nbRemovedRecords > 0) { + if (this.count > this.offset + this.limit) { + // we removed some records, and there are other pages after the current one + return this._load(this.offset, this.limit, this.orderBy, this.domain); + } else { + // we are on the last page and there are still records remaining + this.count -= nbRemovedRecords; + this.records = _records; + } + } + } + + _updateCount(data) { + const length = data.length; + if (length >= this.config.countLimit + 1) { + this.hasLimitedCount = true; + this.count = this.config.countLimit; + } else { + this.hasLimitedCount = false; + this.count = length; + } + } +} diff --git a/addons/web/static/src/model/relational_model/group.js b/addons/web/static/src/model/relational_model/group.js new file mode 100644 index 00000000000..b1afe73d5f7 --- /dev/null +++ b/addons/web/static/src/model/relational_model/group.js @@ -0,0 +1,132 @@ +/* @odoo-module */ + +import { Domain } from "@web/core/domain"; +import { DataPoint } from "./datapoint"; + +/** + * @typedef Params + * @property {string[]} groupBy + */ + +export class Group extends DataPoint { + static type = "Group"; + + /** + * @param {import("./relational_model").Config} config + */ + setup(config, data) { + super.setup(...arguments); + this.groupByField = this.fields[config.groupByFieldName]; + this.range = data.range; + this._rawValue = data[this.groupByField.name]; + /** @type {number} */ + this.count = data.count; + this.value = data.value; + this.displayName = data.displayName; + this.aggregates = data.aggregates; + let List; + if (config.list.groupBy.length) { + List = this.model.constructor.DynamicGroupList; + } else { + List = this.model.constructor.DynamicRecordList; + } + /** @type {import("./dynamic_group_list").DynamicGroupList | import("./dynamic_record_list").DynamicRecordList} */ + this.list = new List(this.model, config.list, data); + if (config.record) { + this.record = new this.model.constructor.Record(this.model, config.record, data.values); + } + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + get hasData() { + return this.list.hasData; + } + get isFolded() { + return this.config.isFolded; + } + get records() { + return this.list.records; + } + get serverValue() { + const { type } = this.groupByField; + switch (type) { + case "many2one": + return this.value || false; + case "many2many": { + return this.value ? [this.value] : false; + } + default: { + return this._rawValue || false; + } + } + } + + // ------------------------------------------------------------------------- + // Public + // ------------------------------------------------------------------------- + + async addExistingRecord(resId, atFirstPosition = false) { + const record = await this.list.addExistingRecord(resId, atFirstPosition); + this.count++; + return record; + } + + async addNewRecord(_unused, atFirstPosition = false) { + const canProceed = await this.model.root.leaveEditMode({ discard: true }); + if (canProceed) { + const record = await this.list.addNewRecord(atFirstPosition); + if (record) { + this.count++; + } + } + } + + async applyFilter(filter) { + if (filter) { + await this.list.load({ + domain: Domain.and([this.config.initialDomain, filter]).toList(), + }); + } else { + await this.list.load({ domain: this.config.initialDomain }); + } + this.model._updateConfig(this.config, { extraDomain: filter }, { noReload: true }); + } + + deleteRecords(records) { + return this.model.mutex.exec(() => this._deleteRecords(records)); + } + + async toggle() { + if (this.config.isFolded) { + await this.list.load(); + } + this.model._updateConfig( + this.config, + { isFolded: !this.config.isFolded }, + { noReload: true } + ); + } + + // ------------------------------------------------------------------------- + // Protected + // ------------------------------------------------------------------------- + + _addRecord(record, index) { + this.list._addRecord(record, index); + this.count++; + } + + async _deleteRecords(records) { + await this.list._deleteRecords(records); + this.count -= records.length; + } + + async _removeRecords(recordIds) { + const idsToRemove = recordIds.filter((id) => this.list.records.some((r) => r.id === id)); + await this.list._removeRecords(idsToRemove); + this.count -= idsToRemove.length; + } +} diff --git a/addons/web/static/src/model/relational_model/record.js b/addons/web/static/src/model/relational_model/record.js new file mode 100644 index 00000000000..f7ae6685cc9 --- /dev/null +++ b/addons/web/static/src/model/relational_model/record.js @@ -0,0 +1,901 @@ +/* @odoo-module */ + +import { markRaw, markup, toRaw } from "@odoo/owl"; +import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { Domain, evalDomain } from "@web/core/domain"; +import { serializeDate, serializeDateTime } from "@web/core/l10n/dates"; +import { _t } from "@web/core/l10n/translation"; +import { pick } from "@web/core/utils/objects"; +import { escape } from "@web/core/utils/strings"; +import { DataPoint } from "./datapoint"; +import { getFieldContext, parseServerValue } from "./utils"; + +export class Record extends DataPoint { + static type = "Record"; + + /** + * @param {import("./relational_model").Config} config + * @param {Object} data + * @param {Object} [options={}] + * @param {boolean} [options.manuallyAdded] + * @param {Function} [options.onUpdate] + * @param {Record} [options.parentRecord] + * @param {string} [options.virtualId] + */ + setup(config, data, options = {}) { + this._manuallyAdded = options.manuallyAdded === true; + this._onUpdate = options.onUpdate || (() => {}); + this._parentRecord = options.parentRecord; + this._virtualId = options.virtualId || false; + + // Be careful that pending changes might not have been notified yet, so the "dirty" flag may + // be false even though there are changes in a field. Consider calling "isDirty()" instead. + this.dirty = false; + this.selected = false; + + this._invalidFields = new Set(); + this._unsetRequiredFields = markRaw(new Set()); + this._closeInvalidFieldsNotification = () => {}; + + let savePoint; + const missingFields = this.fieldNames.filter((fieldName) => !(fieldName in data)); + const vals = this._parseServerValues({ + ...this._getDefaultValues(missingFields), + ...data, + }); + if (this.resId) { + this._values = markRaw(vals); + this._changes = markRaw({}); + savePoint = {}; + } else { + this._values = markRaw({}); + this._changes = markRaw(vals); + savePoint = { ...this._changes }; + } + this._savePoint = markRaw(savePoint); + this.data = { ...this._values, ...this._changes }; + + const parentRecord = this._parentRecord; + if (parentRecord) { + this.evalContext = { + get parent() { + return parentRecord.evalContext; + }, + }; + } else { + this.evalContext = {}; + } + this._setEvalContext(); + } + + // ------------------------------------------------------------------------- + // Getter + // ------------------------------------------------------------------------- + + get canBeAbandoned() { + return this.isNew && !this.dirty && this._manuallyAdded; + } + + get hasData() { + return true; + } + + get isActive() { + if ("active" in this.activeFields) { + return this.data.active; + } else if ("x_active" in this.activeFields) { + return this.data.x_active; + } + return true; + } + + get isInEdition() { + if (this.config.mode === "readonly") { + return false; + } else { + return this.config.mode === "edit" || !this.resId; + } + } + + get isNew() { + return !this.resId; + } + + get isValid() { + return !this._invalidFields.size; + } + + get resId() { + return this.config.resId; + } + + get resIds() { + return this.config.resIds; + } + + // ------------------------------------------------------------------------- + // Public + // ------------------------------------------------------------------------- + + archive() { + return this.model.mutex.exec(() => this._toggleArchive(true)); + } + + async checkValidity() { + if (!this._urgentSave) { + await this._askChanges(); + } + return this._checkValidity(); + } + + delete() { + return this.model.mutex.exec(async () => { + const unlinked = await this.model.orm.unlink(this.resModel, [this.resId], { + context: this.context, + }); + if (!unlinked) { + return false; + } + const resIds = this.resIds.slice(); + const index = resIds.indexOf(this.resId); + resIds.splice(index, 1); + const resId = resIds[Math.min(index, resIds.length - 1)] || false; + if (resId) { + await this._load({ resId, resIds }); + } else { + this.model._updateConfig(this.config, { resId: false }, { noReload: true }); + this.dirty = false; + this._changes = this._parseServerValues(this._getDefaultValues()); + this._values = {}; + this.data = { ...this._changes }; + this._setEvalContext(); + } + }); + } + + discard() { + return this.model.mutex.exec(() => this._discard()); + } + + duplicate() { + return this.model.mutex.exec(async () => { + const kwargs = { context: this.context }; + const index = this.resIds.indexOf(this.resId); + const resId = await this.model.orm.call(this.resModel, "copy", [this.resId], kwargs); + const resIds = this.resIds.slice(); + resIds.splice(index + 1, 0, resId); + await this._load({ resId, resIds, mode: "edit" }); + }); + } + + /** + * @param {string} fieldName + */ + getFieldDomain(fieldName) { + const { domain } = this.fields[fieldName]; + return domain ? new Domain(domain).toList(this.evalContext) : []; + } + + async isDirty() { + await this._askChanges(); + return this.dirty; + } + + /** + * @param {string} fieldName + */ + isFieldInvalid(fieldName) { + return this._invalidFields.has(fieldName); + } + + load(resId = this.resId, context = this.context) { + let mode = this.config.mode; + if (!resId) { + mode = "edit"; + } + return this.model.mutex.exec(() => this._load({ resId, mode, context })); + } + + openInvalidFieldsNotification() { + if (this._invalidFields.size) { + const items = [...this._invalidFields].map((fieldName) => { + return `
  • ${escape(this.fields[fieldName].string || fieldName)}
  • `; + }, this); + this._closeInvalidFieldsNotification = this.model.notification.add( + markup(`
      ${items.join("")}
    `), + { + title: _t("Invalid fields: "), + type: "danger", + } + ); + } + } + + async save(options) { + await this._askChanges(); + return this.model.mutex.exec(() => this._save(options)); + } + + async setInvalidField(fieldName) { + this.dirty = true; + return this._setInvalidField(fieldName); + } + + switchMode(mode) { + return this.model.mutex.exec(() => this._switchMode(mode)); + } + + toggleSelection(selected) { + return this.model.mutex.exec(() => { + if (typeof selected === "boolean") { + this.selected = selected; + } else { + this.selected = !this.selected; + } + }); + } + + unarchive() { + return this.model.mutex.exec(() => this._toggleArchive(false)); + } + + update(changes) { + if (this.model._urgentSave) { + return this._update(changes); + } + return this.model.mutex.exec(() => this._update(changes)); + } + + urgentSave() { + this.model._urgentSave = true; + this.model.bus.trigger("WILL_SAVE_URGENTLY"); + this._save({ noReload: true }); + return this.isValid; + } + + // ------------------------------------------------------------------------- + // Protected + // ------------------------------------------------------------------------- + + _addSavePoint() { + Object.assign(this._savePoint, this._changes); + for (const fieldName in this._changes) { + if (["one2many", "many2many"].includes(this.fields[fieldName].type)) { + this._changes[fieldName]._addSavePoint(); + } + } + } + + _applyChanges(changes) { + for (const fieldName in changes) { + this._changes[fieldName] = changes[fieldName]; + this.data[fieldName] = changes[fieldName]; + } + this._setEvalContext(); + this._removeInvalidFields(Object.keys(changes)); + } + + _applyDefaultValues() { + const fieldNames = this.fieldNames.filter((fieldName) => { + return !(fieldName in this.data); + }); + const defaultValues = this._getDefaultValues(fieldNames); + if (this.isNew) { + this._applyChanges(this._parseServerValues(defaultValues)); + } else { + this._applyValues(defaultValues); + } + } + + _applyValues(values) { + Object.assign(this._values, this._parseServerValues(values)); + Object.assign(this.data, this._values, this._changes); + this._setEvalContext(); + } + + // FIXME: move to model? + _askChanges() { + const proms = []; + this.model.bus.trigger("NEED_LOCAL_CHANGES", { proms }); + return Promise.all(proms); + } + + _checkValidity({ silent } = {}) { + const unsetRequiredFields = []; + for (const fieldName in this.activeFields) { + const fieldType = this.fields[fieldName].type; + if (this._isInvisible(fieldName)) { + continue; + } + switch (fieldType) { + case "boolean": + case "float": + case "integer": + case "monetary": + continue; + case "one2many": + case "many2many": { + const list = this.data[fieldName]; + if ( + (this._isRequired(fieldName) && !list.count) || + !list.records.every((r) => !r.dirty || r._checkValidity({ silent })) + ) { + unsetRequiredFields.push(fieldName); + } + break; + } + default: + if (!this.data[fieldName] && this._isRequired(fieldName)) { + unsetRequiredFields.push(fieldName); + } + } + } + + if (silent) { + return !unsetRequiredFields.length; + } + + for (const fieldName of Array.from(this._unsetRequiredFields)) { + this._invalidFields.delete(fieldName); + } + this._unsetRequiredFields.clear(); + for (const fieldName of unsetRequiredFields) { + this._unsetRequiredFields.add(fieldName); + this._setInvalidField(fieldName); + } + return !this._invalidFields.size; + } + + _computeDataContext() { + const dataContext = {}; + const data = toRaw(this.data); + for (const fieldName in data) { + const value = data[fieldName]; + const field = this.fields[fieldName]; + if (["char", "text"].includes(field.type)) { + dataContext[fieldName] = value !== "" ? value : false; + } else if (["one2many", "many2many"].includes(field.type)) { + dataContext[fieldName] = value.resIds; + } else if (value && field.type === "date") { + dataContext[fieldName] = serializeDate(value); + } else if (value && field.type === "datetime") { + dataContext[fieldName] = serializeDateTime(value); + } else if (value && field.type === "many2one") { + dataContext[fieldName] = value[0]; + } else if (value && field.type === "reference") { + dataContext[fieldName] = `${value.resModel},${value.resId}`; + } else if (field.type === "properties") { + dataContext[fieldName] = value.filter( + (property) => !property.definition_deleted !== false + ); + } else { + dataContext[fieldName] = value; + } + } + dataContext.id = this.resId || false; + return dataContext; + } + + _createStaticListDatapoint(data, fieldName) { + const { related, limit, defaultOrderBy } = this.activeFields[fieldName]; + const config = { + resModel: this.fields[fieldName].relation, + activeFields: (related && related.activeFields) || {}, + fields: (related && related.fields) || {}, + relationField: this.fields[fieldName].relation_field || false, + offset: 0, + resIds: data.map((r) => r.id), + orderBy: defaultOrderBy || [], + limit: limit || Number.MAX_SAFE_INTEGER, + context: {}, // will be set afterwards, see "_updateContext" in "_setEvalContext" + }; + let staticList; + const options = { + onUpdate: ({ withoutOnchange } = {}) => + this._update({ [fieldName]: staticList }, { withoutOnchange }), + parent: this, + }; + staticList = new this.model.constructor.StaticList(this.model, config, data, options); + return staticList; + } + + _discard() { + this.dirty = false; + for (const fieldName in this._changes) { + if (["one2many", "many2many"].includes(this.fields[fieldName].type)) { + this._changes[fieldName]._discard(); + } + } + this._changes = { ...this._savePoint }; + this.data = { ...this._values, ...this._changes }; + this._setEvalContext(); + this._invalidFields.clear(); + this._closeInvalidFieldsNotification(); + this._closeInvalidFieldsNotification = () => {}; + } + + _formatServerValue(fieldType, value) { + if (fieldType === "date") { + return value ? serializeDate(value) : false; + } else if (fieldType === "datetime") { + return value ? serializeDateTime(value) : false; + } else if (fieldType === "char" || fieldType === "text") { + return value !== "" ? value : false; + } else if (fieldType === "many2one") { + return value ? value[0] : false; + } else if (fieldType === "reference") { + return value && value.resModel && value.resId + ? `${value.resModel},${value.resId}` + : false; + } else if (fieldType === "properties") { + return value.map((property) => { + let value; + if (property.type === "many2one") { + value = property.value; + } else if ( + (property.type === "date" || property.type === "datetime") && + typeof property.value === "string" + ) { + // TO REMOVE: need refactoring PropertyField to use the same format as the server + value = property.value; + } else { + value = this._formatServerValue(property.type, property.value); + } + return { + ...property, + value, + }; + }); + } + return value; + } + + _getChanges(changes = this._changes, { withReadonly } = {}) { + const result = {}; + for (const [fieldName, value] of Object.entries(changes)) { + const field = this.fields[fieldName]; + if (fieldName === "id") { + continue; + } + if ( + !withReadonly && + fieldName in this.activeFields && + this._isReadonly(fieldName) && + !this.activeFields[fieldName].forceSave + ) { + continue; + } + if (field.relatedPropertyField) { + continue; + } + if (field.type === "one2many" || field.type === "many2many") { + const commands = value._getCommands({ withReadonly }); + if (commands.length) { + result[fieldName] = commands; + } + } else { + result[fieldName] = this._formatServerValue(field.type, value); + } + } + return result; + } + + _getDefaultValues(fieldNames = this.fieldNames) { + const defaultValues = {}; + for (const fieldName of fieldNames) { + switch (this.fields[fieldName].type) { + case "integer": + case "float": + case "monetary": + defaultValues[fieldName] = fieldName === "id" ? false : 0; + break; + case "one2many": + case "many2many": + defaultValues[fieldName] = []; + break; + default: + defaultValues[fieldName] = false; + } + } + return defaultValues; + } + + _isInvisible(fieldName) { + const invisible = this.activeFields[fieldName].invisible; + return invisible ? evalDomain(invisible, this.evalContext) : false; + } + + _isReadonly(fieldName) { + const readonly = this.activeFields[fieldName].readonly; + return readonly ? evalDomain(readonly, this.evalContext) : false; + } + + _isRequired(fieldName) { + const required = this.activeFields[fieldName].required; + return required ? evalDomain(required, this.evalContext) : false; + } + + async _load(nextConfig = {}) { + // FIXME: do not allow to change resId? maybe add a new method on model to re-generate a + // new root for the new resId + const values = await this.model._updateConfig(this.config, nextConfig); + if (this.resId) { + this.model._updateSimilarRecords(this, values); + this._values = this._parseServerValues(values); + this._changes = {}; + } else { + this._values = {}; + this._changes = this._parseServerValues({ ...this._getDefaultValues(), ...values }); + } + this.dirty = false; + this.data = { ...this._values, ...this._changes }; + this._setEvalContext(); + this._invalidFields.clear(); + } + + _parseServerValues(serverValues, currentValues = {}) { + const parsedValues = {}; + if (!serverValues) { + return parsedValues; + } + for (const fieldName in serverValues) { + const value = serverValues[fieldName]; + if (!this.activeFields[fieldName]) { + continue; + } + const field = this.fields[fieldName]; + if (field.type === "one2many" || field.type === "many2many") { + let staticList = currentValues[fieldName]; + let valueIsCommandList = true; + // value can be a list of records or a list of commands (new record) + valueIsCommandList = value.length > 0 && Array.isArray(value[0]); + if (!staticList) { + let data = valueIsCommandList ? [] : value; + if (data.length > 0 && typeof data[0] === "number") { + data = data.map((resId) => { + return { id: resId }; + }); + } + staticList = this._createStaticListDatapoint(data, fieldName); + } + if (valueIsCommandList) { + staticList._applyCommands(value); + } + parsedValues[fieldName] = staticList; + } else { + parsedValues[fieldName] = parseServerValue(field, value); + if (field.type === "properties") { + for (const property of parsedValues[fieldName]) { + const fieldPropertyName = `${fieldName}.${property.name}`; + if (property.type === "one2many" || property.type === "many2many") { + const staticList = this._createStaticListDatapoint( + property.value.map((record) => ({ + id: record[0], + display_name: record[1], + })), + fieldPropertyName + ); + parsedValues[fieldPropertyName] = staticList; + } else if (property.type === "many2one") { + parsedValues[fieldPropertyName] = + property.value.length && property.value[1] === null + ? [property.value[0], this.model.env._t("No Access")] + : property.value; + } else { + parsedValues[fieldPropertyName] = property.value ?? false; + } + } + } + } + } + return parsedValues; + } + + async _preprocessChanges(changes) { + await Promise.all([ + this._preprocessMany2oneChanges(changes), + this._preprocessReferenceChanges(changes), + ]); + } + + async _preprocessReferenceChanges(changes) { + const proms = []; + for (const [fieldName, value] of Object.entries(changes)) { + if (this.fields[fieldName].type !== "reference") { + continue; + } + if (!value) { + changes[fieldName] = false; + continue; + } + const id = value.resId; + const displayName = value.displayName; + if (!id && !displayName) { + changes[fieldName] = false; + continue; + } + const context = getFieldContext(this, fieldName); + + if (!id && displayName !== undefined) { + proms.push( + this.model.orm + .call(value.resModel, "name_create", [displayName], { + context, + }) + .then((result) => { + changes[fieldName] = result + ? { + resModel: value.resModel, + resId: result[0], + displayName, + } + : false; + }) + ); + } else if (id && displayName === undefined) { + const kwargs = { + context, + specification: { display_name: {} }, + }; + proms.push( + this.model.orm + .call(value.resModel, "web_read", [[id]], kwargs) + .then((records) => { + changes[fieldName] = { + resModel: value.resModel, + resId: id, + displayName: records[0].display_name, + }; + }) + ); + } else { + changes[fieldName] = value; + } + } + return Promise.all(proms); + } + + async _preprocessMany2oneChanges(changes) { + const proms = []; + for (const [fieldName, value] of Object.entries(changes)) { + if (this.fields[fieldName].type !== "many2one") { + continue; + } + if (!value) { + changes[fieldName] = false; + continue; + } + const [id, displayName] = value; + if (!id && !displayName) { + changes[fieldName] = [false, ""]; + continue; + } + + const activeField = this.activeFields[fieldName]; + + if (!activeField) { + changes[fieldName] = value; + continue; + } + + const relation = this.fields[fieldName].relation; + const context = getFieldContext(this, fieldName); + + if (!id && displayName !== undefined) { + proms.push( + this.model.orm + .call(relation, "name_create", [displayName], { + context, + }) + .then((result) => { + changes[fieldName] = result ? result : [false, ""]; + }) + ); + } else if (id && displayName === undefined) { + const kwargs = { + context, + specification: { display_name: {} }, + }; + proms.push( + this.model.orm.call(relation, "web_read", [[id]], kwargs).then((records) => { + changes[fieldName] = [records[0].id, records[0].display_name || ""]; + }) + ); + } else { + changes[fieldName] = value; + } + } + return Promise.all(proms); + } + + _removeInvalidFields(fieldNames) { + for (const fieldName of fieldNames) { + this._invalidFields.delete(fieldName); + } + } + + async _save({ noReload, onError } = {}) { + // before saving, abandon new invalid, untouched records in x2manys + for (const fieldName in this.activeFields) { + if (["one2many", "many2many"].includes(this.fields[fieldName].type)) { + this.data[fieldName]._abandonRecords(); + } + } + if (!this._checkValidity()) { + this.openInvalidFieldsNotification(); + return false; + } + const changes = this._getChanges(); + const creation = !this.resId; + delete changes.id; // id never changes, and should not be written + if (!creation && !Object.keys(changes).length) { + return true; + } + const canProceed = await this.model.hooks.onWillSaveRecord(this, changes); + if (canProceed === false) { + return false; + } + const kwargs = { context: this.context }; + let resId = this.resId; + try { + if (creation) { + [resId] = await this.model.orm.create(this.resModel, [changes], kwargs); + } else { + await this.model.orm.write(this.resModel, [resId], changes, kwargs); + } + } catch (e) { + if (onError) { + return onError(e, { discard: () => this._discard() }); + } + if (!this.isInEdition) { + await this._load({ resId }); + } + throw e; + } + if (!noReload) { + const nextConfig = { resId }; + if (creation) { + nextConfig.resIds = this.resIds.concat([resId]); + } + await this._load(nextConfig); + } else { + this.model._updateConfig(this.config, { resId }, { noReload: true }); + this._values = { ...this._values, ...this._changes }; + if ("id" in this.activeFields) { + this._values.id = resId; + } + this._changes = {}; + this.data = { ...this._values }; + this.dirty = false; + } + await this.model.hooks.onRecordSaved(this, changes); + return true; + } + + /** + * For owl reactivity, it's better to only update the keys inside the evalContext + * instead of replacing the evalContext itself, because a lot of components are + * registered to the evalContext (but not necessarily keys inside it), and would + * be uselessly re-rendered if we replace it by a brand new object. + */ + _setEvalContext() { + Object.assign(this.evalContext, { + ...this.context, + active_id: this.resId || false, + active_ids: this.resId ? [this.resId] : [], + active_model: this.resModel, + current_company_id: this.model.company.currentCompany.id, + ...this._computeDataContext(), + }); + + for (const [fieldName, value] of Object.entries(toRaw(this.data))) { + if ( + this.fields[fieldName].type === "one2many" || + this.fields[fieldName].type === "many2many" + ) { + value._updateContext(getFieldContext(this, fieldName)); + } + } + } + + async _setInvalidField(fieldName) { + const canProceed = this.model.hooks.onWillSetInvalidField(this, fieldName); + if (canProceed === false) { + return; + } + if (this.selected && this.model.multiEdit && !this._invalidFields.has(fieldName)) { + await this.model.dialog.add(AlertDialog, { + body: _t("No valid record to save"), + confirm: async () => { + await this.discard(); + this.switchMode("readonly"); + }, + }); + } + this._invalidFields.add(fieldName); + } + + _switchMode(mode) { + this.model._updateConfig(this.config, { mode }, { noReload: true }); + if (mode === "readonly") { + this._invalidFields.clear(); + } + } + + /** + * @param {boolean} state archive the records if true, otherwise unarchive them + */ + async _toggleArchive(state) { + const method = state ? "action_archive" : "action_unarchive"; + const context = this.context; + const resId = this.resId; + const action = await this.model.orm.call(this.resModel, method, [[resId]], { context }); + if (action && Object.keys(action).length) { + this.model.action.doAction(action, { onClose: () => this._load({ resId }) }); + } else { + return this._load({ resId }); + } + } + + async _update(changes, { withoutOnchange, withoutParentUpdate } = {}) { + this.dirty = true; + const prom = this._preprocessChanges(changes); + if (prom && !this.model._urgentSave) { + await prom; + } + if (this.selected && this.model.multiEdit) { + this._applyChanges(changes); + return this.model.root._multiSave(this); + } + for (const [fieldName, value] of Object.entries(changes)) { + const field = this.fields[fieldName]; + if (field && field.relatedPropertyField) { + const propertyFieldName = field.relatedPropertyField.fieldName; + changes[propertyFieldName] = this.data[propertyFieldName].map((property) => + property.name === field.propertyName ? { ...property, value } : property + ); + } + } + const onChangeFields = Object.keys(changes).filter( + (fieldName) => this.activeFields[fieldName] && this.activeFields[fieldName].onChange + ); + if (onChangeFields.length && !this.model._urgentSave && !withoutOnchange) { + const localChanges = this._getChanges( + { ...this._changes, ...changes }, + { withReadonly: true } + ); + if (this.config.relationField) { + localChanges[this.config.relationField] = this._parentRecord._getChanges(); + } + const otherChanges = await this.model._onchange(this.config, { + changes: localChanges, + fieldNames: onChangeFields, + evalContext: this.evalContext, + }); + Object.assign(changes, this._parseServerValues(otherChanges, this.data)); + } + // changes inside the record set as value for a many2one field must trigger the onchange, + // but can't be considered as changes on the parent record, so here we detect if many2one + // fields really changed, and if not, we delete them from changes + for (const fieldName in changes) { + if (this.fields[fieldName].type === "many2one") { + const curVal = toRaw(this.data[fieldName]); + const nextVal = changes[fieldName]; + if (curVal && nextVal && curVal[0] === nextVal[0] && curVal[1] === nextVal[1]) { + delete changes[fieldName]; + } + } + } + if (Object.keys(changes).length > 0) { + const initialChanges = pick(this._changes, ...Object.keys(changes)); + this._applyChanges(changes); + try { + await this._onUpdate(changes, { withoutParentUpdate }); + } catch (e) { + this._applyChanges(initialChanges); + throw e; + } + await this.model.hooks.onRecordChanged(this, this._getChanges()); + } + } +} diff --git a/addons/web/static/src/model/relational_model/relational_model.js b/addons/web/static/src/model/relational_model/relational_model.js new file mode 100644 index 00000000000..9acc0fecaa2 --- /dev/null +++ b/addons/web/static/src/model/relational_model/relational_model.js @@ -0,0 +1,657 @@ +/* @odoo-module */ +// @ts-check + +import { EventBus, markRaw } from "@odoo/owl"; +import { makeContext } from "@web/core/context"; +import { Domain } from "@web/core/domain"; +import { WarningDialog } from "@web/core/errors/error_dialogs"; +import { registry } from "@web/core/registry"; +import { shallowEqual, unique } from "@web/core/utils/arrays"; +import { KeepLast, Mutex } from "@web/core/utils/concurrency"; +import { orderByToString } from "@web/search/utils/order_by"; +import { Model } from "../model"; +import { DynamicGroupList } from "./dynamic_group_list"; +import { DynamicRecordList } from "./dynamic_record_list"; +import { Group } from "./group"; +import { Record } from "./record"; +import { StaticList } from "./static_list"; +import { + getFieldsSpec, + createPropertyActiveField, + getAggregatesFromGroupData, + getDisplayNameFromGroupData, + getValueFromGroupData, + isRelational, +} from "./utils"; + +/** + * @typedef Params + * @property {Config} config + * @property {State} [state] + * @property {Hooks} [hooks] + * @property {number} [limit] + * @property {number} [countLimit] + * @property {number} [groupsLimit] + * @property {string[]} [defaultOrderBy] + * @property {string[]} [defaultGroupBy] + * @property {number} [maxGroupByDepth] + * @property {boolean} [multiEdit] + * @property {Object} [groupByInfo] + */ + +/** + * @typedef Config + * @property {string} resModel + * @property {Object} fields + * @property {Object} activeFields + * @property {object} context + * @property {boolean} isMonoRecord + * @property {boolean} isRoot + * @property {Array} [domain] + * @property {Array} [groupBy] + * @property {Array} [orderBy] + * @property {number} [resId] + * @property {number[]} [resIds] + * @property {string} [mode] + * @property {number} [limit] + * @property {number} [offset] + * @property {number} [countLimit] + * @property {number} [groupsLimit] + * @property {Object} [groups] + * @property {boolean} [openGroupsByDefault] + */ + +/** + * @typedef Hooks + * @property {Function} [onWillLoadRoot] + * @property {Function} [onWillSaveRecord] + * @property {Function} [onRecordSaved] + * @property {Function} [onWillSaveMulti] + * @property {Function} [onSavedMulti] + * @property {Function} [onWillSetInvalidField] + * @property {Function} [onRecordChanged] + */ + +/** + * @typedef State + * @property {Config} config + * @property {Object} specialDataCaches + */ + +const DEFAULT_HOOKS = { + onWillLoadRoot: () => {}, + onWillSaveRecord: () => {}, + onRecordSaved: () => {}, + onWillSaveMulti: () => {}, + onSavedMulti: () => {}, + onWillSetInvalidField: () => {}, + onRecordChanged: () => {}, +}; + +export class FetchRecordError extends Error { + constructor(resIds) { + super(`Can't fetch record(s) ${resIds}. They might have been deleted.`); + } +} + +export function fetchRecordErrorHandler(env, error, originalError) { + if (originalError instanceof FetchRecordError) { + env.services.notification.add(originalError.message, { sticky: true, type: "danger" }); + return true; + } +} +const errorHandlerRegistry = registry.category("error_handlers"); +errorHandlerRegistry.add("fetchRecordErrorHandler", fetchRecordErrorHandler); + +export class RelationalModel extends Model { + static services = ["action", "company", "dialog", "notification", "orm", "rpc", "user"]; + static Record = Record; + static Group = Group; + static DynamicRecordList = DynamicRecordList; + static DynamicGroupList = DynamicGroupList; + static StaticList = StaticList; + static DEFAULT_LIMIT = 80; + static DEFAULT_COUNT_LIMIT = 10000; + static DEFAULT_GROUP_LIMIT = 80; + static DEFAULT_OPEN_GROUP_LIMIT = 10; + static MAX_NUMBER_OPENED_GROUPS = 10; + + /** + * @param {Params} params + */ + setup(params, { action, company, dialog, notification, rpc, user }) { + this.action = action; + this.company = company; + this.dialog = dialog; + this.notification = notification; + this.rpc = rpc; + this.user = user; + + this.bus = new EventBus(); + + this.keepLast = markRaw(new KeepLast()); + this.mutex = markRaw(new Mutex()); + + /** @type {Config} */ + this.config = { + isMonoRecord: false, + ...params.config, + isRoot: true, + }; + + /** @type {Hooks} */ + this.hooks = Object.assign({}, DEFAULT_HOOKS, params.hooks); + + this.initialLimit = params.limit || this.constructor.DEFAULT_LIMIT; + this.initialGroupsLimit = params.groupsLimit; + this.initialCountLimit = params.countLimit || this.constructor.DEFAULT_COUNT_LIMIT; + this.defaultOrderBy = params.defaultOrderBy; + this.defaultGroupBy = params.defaultGroupBy; + this.maxGroupByDepth = params.maxGroupByDepth; + this.groupByInfo = params.groupByInfo || {}; + this.multiEdit = params.multiEdit; + this.activeIdsLimit = params.activeIdsLimit || Number.MAX_SAFE_INTEGER; + this.specialDataCaches = markRaw(params.state?.specialDataCaches || {}); + + this._urgentSave = false; + } + + // ------------------------------------------------------------------------- + // Public + // ------------------------------------------------------------------------- + + exportState() { + return { + config: this.config, + specialDataCaches: this.specialDataCaches, + }; + } + + hasData() { + return this.root.hasData; + } + + /** + * @param {Object} [params={}] + * @param {Comparison | null} [params.comparison] + * @param {Context} [params.context] + * @param {DomainListRepr} [params.domain] + * @param {string[]} [params.groupBy] + * @param {Object[]} [params.orderBy] + * @returns {Promise} + */ + async load(params = {}) { + const config = this._getNextConfig(this.config, params); + const data = await this.keepLast.add(this._loadData(config)); + this.root = this._createRoot(config, data); + this.config = config; + } + + // ------------------------------------------------------------------------- + // Protected + // ------------------------------------------------------------------------- + + _applyProperties(records, config) { + for (const record of records) { + for (const fieldName in record) { + const field = config.fields[fieldName]; + if (fieldName !== "id" && field.type === "properties") { + const parent = record[field.definition_record]; + const relatedPropertyField = { + fieldName, + }; + if (parent) { + relatedPropertyField.id = parent.id; + relatedPropertyField.displayName = parent.display_name; + } + for (const property of record[fieldName]) { + const propertyFieldName = `${fieldName}.${property.name}`; + if (!config.fields[propertyFieldName]) { + config.fields[propertyFieldName] = { + ...property, + name: propertyFieldName, + relatedPropertyField, + propertyName: property.name, + relation: property.comodel, + }; + config.activeFields[propertyFieldName] = + createPropertyActiveField(property); + } + } + } + } + } + } + + /** + * + * @param {Config} config + * @param {*} data + * @returns {DataPoint} + */ + _createRoot(config, data) { + if (config.isMonoRecord) { + return new this.constructor.Record(this, config, data); + } + if (config.groupBy.length) { + return new this.constructor.DynamicGroupList(this, config, data); + } + return new this.constructor.DynamicRecordList(this, config, data); + } + + /** + * @param {*} params + * @returns {Config} + */ + _getNextConfig(currentConfig, params) { + const currentGroupBy = currentConfig.groupBy; + const config = Object.assign({}, currentConfig); + + config.context = "context" in params ? params.context : config.context; + if (!currentConfig.isMonoRecord) { + config.domain = "domain" in params ? params.domain : config.domain; + config.comparison = "comparison" in params ? params.comparison : config.comparison; + + // groupBy + config.groupBy = "groupBy" in params ? params.groupBy : config.groupBy; + // apply default groupBy if any + if (this.defaultGroupBy && !config.groupBy.length) { + config.groupBy = [this.defaultGroupBy]; + } + // restrict the number of groupbys if requested + if (this.maxGroupByDepth) { + config.groupBy = config.groupBy.slice(0, this.maxGroupByDepth); + } + + // orderBy + config.orderBy = "orderBy" in params ? params.orderBy : config.orderBy; + // re-apply previous orderBy if not given (or no order) + if (!config.orderBy.length) { + config.orderBy = currentConfig.orderBy || []; + } + // apply default order if no order + if (this.defaultOrderBy && !config.orderBy.length) { + config.orderBy = this.defaultOrderBy; + } + + // keep current root config if any, if the groupBy parameter is the same + if (!shallowEqual(config.groupBy || [], currentGroupBy || [])) { + delete config.groups; + } + } + if (!config.isMonoRecord && this.root) { + // always reset the offset to 0 when reloading from above + config.offset = 0; + } + + return config; + } + + /** + * + * @param {Config} config + */ + async _loadData(config) { + if (config.isRoot) { + this.hooks.onWillLoadRoot(); + } + if (config.isMonoRecord) { + const evalContext = { + ...config.context, + active_id: config.resId, + active_ids: [config.resId], + active_model: config.resModel, + current_company_id: this.company.currentCompany.id, + }; + if (!config.resId) { + return this._loadNewRecord(config, { evalContext }); + } + + const records = await this._loadRecords( + { + ...config, + resIds: [config.resId], + }, + evalContext + ); + return records[0]; + } + if (config.resIds) { + // static list + const resIds = config.resIds.slice(config.offset, config.offset + config.limit); + return this._loadRecords({ ...config, resIds }); + } + if (config.groupBy.length) { + return this._loadGroupedList(config); + } + Object.assign(config, { + limit: config.limit || this.initialLimit, + countLimit: "countLimit" in config ? config.countLimit : this.initialCountLimit, + offset: config.offset || 0, + }); + if (config.countLimit !== Number.MAX_SAFE_INTEGER) { + config.countLimit = Math.max(config.countLimit, config.offset + config.limit); + } + return this._loadUngroupedList({ + ...config, + context: { + ...config.context, + current_company_id: this.company.currentCompany.id, + }, + }); + } + + /** + * @param {Config} config + */ + async _loadGroupedList(config) { + config.offset = config.offset || 0; + config.limit = config.limit || this.initialGroupsLimit; + if (!config.limit) { + config.limit = config.openGroupsByDefault + ? this.constructor.DEFAULT_OPEN_GROUP_LIMIT + : this.constructor.DEFAULT_GROUP_LIMIT; + } + config.groups = config.groups || {}; + const firstGroupByName = config.groupBy[0].split(":")[0]; + const orderBy = config.orderBy.filter( + (o) => + o.name === firstGroupByName || + (o.name in config.activeFields && + config.fields[o.name].group_operator !== undefined) + ); + const response = await this._webReadGroup(config, firstGroupByName, orderBy); + const { groups, length } = response; + const groupBy = config.groupBy.slice(1); + const groupByField = config.fields[config.groupBy[0].split(":")[0]]; + const commonConfig = { + resModel: config.resModel, + fields: config.fields, + activeFields: config.activeFields, + }; + let groupRecordConfig; + const groupRecordResIds = []; + if (this.groupByInfo[firstGroupByName]) { + groupRecordConfig = { + ...this.groupByInfo[firstGroupByName], + resModel: config.fields[firstGroupByName].relation, + context: {}, + }; + } + const proms = []; + let nbOpenGroups = 0; + for (const group of groups) { + // When group_by_no_leaf key is present FIELD_ID_count doesn't exist + // we have to get the count from `__count` instead + // see _read_group_raw in models.py + group.count = group.__count || group[`${firstGroupByName}_count`]; + group.length = group.count; + group.range = group.__range ? group.__range[config.groupBy[0]] : null; + delete group.__count; + delete group[`${firstGroupByName}_count`]; + delete group.__range; + group.value = getValueFromGroupData(group, groupByField, group[config.groupBy[0]]); + group.displayName = getDisplayNameFromGroupData(groupByField, group[config.groupBy[0]]); + group.aggregates = getAggregatesFromGroupData(group, config.fields); + // delete group[config.groupBy[0]]; + if (!config.groups[group.value]) { + config.groups[group.value] = { + ...commonConfig, + groupByFieldName: groupByField.name, + isFolded: "__fold" in group ? group.__fold : !config.openGroupsByDefault, + extraDomain: false, + value: group.value, + list: { + ...commonConfig, + groupBy, + }, + }; + if (groupRecordConfig) { + config.groups[group.value].record = { + ...groupRecordConfig, + resId: group.value ?? false, + }; + } + } + if (groupRecordConfig) { + const resId = config.groups[group.value].record.resId; + if (resId) { + groupRecordResIds.push(resId); + } + } + const groupConfig = config.groups[group.value]; + groupConfig.list.orderBy = config.orderBy; + groupConfig.initialDomain = group.__domain; + if (groupConfig.extraDomain) { + groupConfig.list.domain = Domain.and([ + group.__domain, + groupConfig.extraDomain, + ]).toList(); + } else { + groupConfig.list.domain = group.__domain; + } + const context = { + ...config.context, + [`default_${firstGroupByName}`]: group.value, + }; + groupConfig.list.context = context; + groupConfig.context = context; + if (groupBy.length) { + group.groups = []; + } else { + group.records = []; + } + if (isRelational(config.fields[firstGroupByName]) && !group.value) { + groupConfig.isFolded = true; + } + if (!groupConfig.isFolded) { + nbOpenGroups++; + if (nbOpenGroups > this.constructor.MAX_NUMBER_OPENED_GROUPS) { + groupConfig.isFolded = true; + } + } + if (!groupConfig.isFolded && group.count > 0) { + const prom = this._loadData(groupConfig.list).then((response) => { + if (groupBy.length) { + group.groups = response ? response.groups : []; + } else { + group.records = response ? response.records : []; + } + }); + proms.push(prom); + } + } + if (groupRecordConfig && Object.keys(groupRecordConfig.activeFields).length) { + const prom = this._loadRecords({ + ...groupRecordConfig, + resIds: groupRecordResIds, + }).then((records) => { + for (const group of groups) { + group.values = records.find((r) => group.value && r.id === group.value); + } + }); + proms.push(prom); + } + await Promise.all(proms); + + // if a group becomes empty at some point (e.g. we dragged its last record out of it), and the view is reloaded + // with the same domain and groupbys, we want to keep the empty group in the UI + if ( + config.currentGroups && + config.currentGroups.params === + JSON.stringify([config.domain, config.groupBy, config.offset, config.limit]) + ) { + const currentGroups = config.currentGroups.groups; + for (const group of currentGroups) { + if ( + config.groups[group.value] && + !groups.some((g) => JSON.stringify(g.value) === JSON.stringify(group.value)) + ) { + groups.push(Object.assign({}, group, { count: 0, length: 0, records: [] })); + } + } + } + config.currentGroups = { + params: JSON.stringify([config.domain, config.groupBy, config.offset, config.limit]), + groups, + }; + + return { groups, length }; + } + + /** + * @param {Config} config + * @param {Object} [params={}] + * @returns Promise + */ + _loadNewRecord(config, params = {}) { + return this._onchange(config, params); + } + + /** + * + * @param {Config} config + * @param {object} evalContext + * @returns + */ + async _loadRecords(config, evalContext = config.context) { + const { resModel, resIds, activeFields, fields, context } = config; + if (!resIds.length) { + return []; + } + const kwargs = { + context: { bin_size: true, ...context }, + specification: getFieldsSpec(activeFields, fields, evalContext), + }; + const records = await this.orm.call(resModel, "web_read", [resIds], kwargs); + if (!records.length) { + throw new FetchRecordError(resIds); + } + + this._applyProperties(records, config); + return records; + } + + /** + * Load records from the server for an ungrouped list. Return the result + * of unity read RPC. + * + * @param {Config} config + * @returns + */ + async _loadUngroupedList(config) { + const kwargs = { + specification: getFieldsSpec(config.activeFields, config.fields, config.context), + domain: config.domain, + offset: config.offset, + order: orderByToString(config.orderBy), + limit: config.limit, + context: { bin_size: true, ...config.context }, + count_limit: + config.countLimit !== Number.MAX_SAFE_INTEGER ? config.countLimit + 1 : undefined, + }; + const response = await this.orm.call(config.resModel, "unity_web_search_read", [], kwargs); + + this._applyProperties(response.records, config); + return response; + } + + /** + * @param {Config} config + * @param {Object} param + * @param {Object} [param.changes={}] + * @param {string[]} [param.fieldNames=[]] + * @param {Object} [param.evalContext=config.context] + * @returns Promise + */ + async _onchange(config, { changes = {}, fieldNames = [], evalContext = config.context }) { + const { fields, activeFields, resModel, resId } = config; + let context = config.context; + if (fieldNames.length === 1) { + const fieldContext = config.activeFields[fieldNames[0]].context; + context = makeContext([context, fieldContext], evalContext); + } + const spec = getFieldsSpec(activeFields, fields, evalContext, { withInvisible: true }); + const args = [resId ? [resId] : [], changes, fieldNames, spec]; + const response = await this.orm.call(resModel, "onchange2", args, { context }); + if (response.warning) { + const { type, title, message, className, sticky } = response.warning; + if (type === "dialog") { + this.dialog.add(WarningDialog, { title, message }); + } else { + this.notification.add(message, { + className, + sticky, + title, + type: "warning", + }); + } + } + return response.value; + } + + /** + * + * @param {Config} config + * @param {Partial} patch + * @param {Object} [options] + * @param {boolean} [options.noReload=false] + */ + async _updateConfig(config, patch, options = {}) { + const tmpConfig = { ...config, ...patch }; + let response; + if (!options.noReload) { + response = await this._loadData(tmpConfig); + } + Object.assign(config, tmpConfig); + return response; + } + + /** + * + * @param {Config} config + * @returns {Promise} + */ + async _updateCount(config) { + const count = await this.keepLast.add(this.orm.searchCount(config.resModel, config.domain)); + config.countLimit = Number.MAX_SAFE_INTEGER; + return count; + } + + /** + * When grouped by a many2many field, the same record may be displayed in + * several groups. When one of these records is edited, we want all other + * occurrences to be updated. The purpose of this function is to find and + * update all occurrences of a record that has been reloaded, in a grouped + * list view. + */ + _updateSimilarRecords(reloadedRecord, serverValues) { + if (this.config.isMonoRecord || !this.config.groupBy.length) { + return; + } + for (const record of this.root.records) { + if (record === reloadedRecord) { + continue; + } + if (record.resId === reloadedRecord.resId) { + record._applyValues(serverValues); + } + } + } + + async _webReadGroup(config, firstGroupByName, orderBy) { + return this.orm.webReadGroup( + config.resModel, + config.domain, + unique([...Object.keys(config.activeFields), firstGroupByName]), + [config.groupBy[0]], + { + orderby: orderByToString(orderBy), + lazy: true, // maybe useless + offset: config.offset, + limit: config.limit, + context: config.context, + } + ); + } +} diff --git a/addons/web/static/src/model/relational_model/static_list.js b/addons/web/static/src/model/relational_model/static_list.js new file mode 100644 index 00000000000..63a01dc7dd5 --- /dev/null +++ b/addons/web/static/src/model/relational_model/static_list.js @@ -0,0 +1,890 @@ +/* @odoo-module */ + +import { x2ManyCommands } from "@web/core/orm_service"; +import { intersection } from "@web/core/utils/arrays"; +import { pick } from "@web/core/utils/objects"; +import { DataPoint } from "./datapoint"; +import { fromUnityToServerValues, getId, patchActiveFields } from "./utils"; + +import { markRaw } from "@odoo/owl"; + +function compareFieldValues(v1, v2, fieldType) { + if (fieldType === "many2one") { + v1 = v1 ? v1[1] : false; + v2 = v2 ? v2[1] : false; + } + return v1 < v2; +} + +function compareRecords(r1, r2, orderBy, fields) { + const { name, asc } = orderBy[0]; + const v1 = asc ? r1.data[name] : r2.data[name]; + const v2 = asc ? r2.data[name] : r1.data[name]; + if (compareFieldValues(v1, v2, fields[name].type)) { + return -1; + } + if (compareFieldValues(v2, v1, fields[name].type)) { + return 1; + } + if (orderBy.length > 1) { + return compareRecords(r1, r2, orderBy.slice(1)); + } + return 0; +} + +export class StaticList extends DataPoint { + static type = "StaticList"; + + /** + * @param {import("./relational_model").Config} config + * @param {Object} data + * @param {Object} [options={}] + * @param {Function} [options.onUpdate] + * @param {Record} [options.parent] + */ + setup(config, data, options = {}) { + this._parent = options.parent; + this._onUpdate = options.onUpdate; + + this._cache = markRaw({}); + this._commands = []; + this._savePoint = markRaw({}); + this._unknownRecordCommands = {}; // tracks update commands on records we haven't fetched yet + this._currentIds = [...this.resIds]; + this._needsReordering = false; + this._tmpIncreaseLimit = 0; + // In kanban and non editable list views, x2many records can be opened in a form view in + // dialog, which may contain other fields than the kanban or list view. The next set keeps + // tracks of records we already opened in dialog and thus for which we already modified the + // config to add the form view's fields in activeFields. + this._extendedRecords = new Set(); + + this.records = data + .slice(this.offset, this.limit) + .map((r) => this._createRecordDatapoint(r)); + this.count = this.resIds.length; + this.handleField = Object.keys(this.activeFields).find( + (fieldName) => this.activeFields[fieldName].isHandle + ); + } + + // ------------------------------------------------------------------------- + // Getters + // ------------------------------------------------------------------------- + + get currentIds() { + return this._currentIds; + } + + get editedRecord() { + return this.records.find((record) => record.isInEdition); + } + + get evalContext() { + return { + ...this.config.context, + parent: this._parent.evalContext, + }; + } + + get limit() { + return this.config.limit; + } + + get offset() { + return this.config.offset; + } + + get orderBy() { + return this.config.orderBy; + } + + get resIds() { + return this.config.resIds; + } + + // ------------------------------------------------------------------------- + // Public + // ------------------------------------------------------------------------- + + /** + * Adds a new record to an x2many relation. If params.record is given, adds + * given record (use case: after saving the form dialog in a, e.g., non + * editable x2many list). Otherwise, do an onchange to get the initial + * values and create a new Record (e.g. after clicking on Add a line in an + * editable x2many list). + * + * @param {Object} params + * @param {"top"|"bottom"} [params.position] + * @param {Object} [params.activeFields=this.activeFields] + * @param {boolean} [params.withoutParent=false] + */ + addNewRecord(params) { + return this.model.mutex.exec(async () => { + const { activeFields, context, mode, position, withoutParent } = params; + const record = await this._createNewRecordDatapoint({ + activeFields, + context, + position, + withoutParent, + manuallyAdded: true, + mode, + }); + await this._addRecord(record, { position }); + await this._onUpdate({ withoutOnchange: !record._checkValidity({ silent: true }) }); + return record; + }); + } + + canResequence() { + return this.handleField && this.orderBy.length && this.orderBy[0].name === this.handleField; + } + + /** + * TODO: We should probably delete this function. + * It is only used for the product configurator. + * It will take a list of contexts containing default + * values used to create the new records in the static list. + * It will then delete the old records from the static list + * and replace them with the new ones we have just created. + */ + createAndReplace(contextRecords) { + return this.model.mutex.exec(async () => { + const proms = []; + for (const context of contextRecords) { + proms.push( + this._createNewRecordDatapoint({ + context, + manuallyAdded: true, + }) + ); + } + this.records = await Promise.all(proms); + this._commands = this.records.map((record) => [ + x2ManyCommands.CREATE, + record._virtualId, + ]); + this._currentIds = this.records.map((record) => record._virtualId); + }); + } + + async delete(record) { + await this._applyCommands([[x2ManyCommands.DELETE, record.resId || record._virtualId]]); + this._onUpdate(); + } + + async enterEditMode(record) { + const canProceed = await this.leaveEditMode(); + if (canProceed) { + await record.switchMode("edit"); + } + return canProceed; + } + + /** + * This method is meant to be used in a very specific usecase: when an x2many record is viewed + * or edited through a form view dialog (e.g. x2many kanban or non editable list). In this case, + * the form typically contains different fields than the kanban or list, so we need to "extend" + * the fields and activeFields. If the record opened in a form view dialog already exists, we + * modify it's config to add the new fields. If it is a new record, we create it with the + * extended config. + * + * @param {Object} params + * @param {Object} params.activeFields + * @param {Object} params.fields + * @param {Object} [params.context] + * @param {boolean} [params.withoutParent] + * @param {Record} [record] + * @returns {Record} + */ + extendRecord(params, record) { + return this.model.mutex.exec(async () => { + // extend fields and activeFields of the list with those given in params + Object.assign(this.fields, params.fields); + const activeFields = { ...params.activeFields }; + for (const fieldName in this.activeFields) { + if (fieldName in activeFields) { + patchActiveFields(activeFields[fieldName], this.activeFields[fieldName]); + } else { + activeFields[fieldName] = this.activeFields[fieldName]; + } + } + + if (record) { + // case 1: the record already exists + if (this._extendedRecords.has(record.id)) { + // case 1.1: the record has already been extended + // -> simply switch it to edit and store a savepoint + record._switchMode("edit"); + record._addSavePoint(); + return record; + } + // case 1.2: the record is extended for the first time, and it now potentially has + // more fields than before (or x2many fields displayed differently) + // -> if it isn't a new record, load it to retrieve the values of new fields + // -> generate default values for new fields + // -> recursively update the config of the record and it's sub datapoints + // -> apply the loaded values in the case of a not new record + // -> store a savepoint + // These operations must be done in that specific order to ensure that the model is + // mutated only once (in a tick), and that datapoints have the correct config to + // handle field values they receive. + const config = { + ...record.config, + ...params, + activeFields, + }; + let data = {}; + if (!record.isNew) { + const evalContext = Object.assign({}, record.evalContext, config.context); + const resIds = [record.resId]; + [data] = await this.model._loadRecords({ ...config, resIds }, evalContext); + } + this.model._updateConfig(record.config, config, { noReload: true }); + record._applyDefaultValues(); + for (const fieldName in record.activeFields) { + if (["one2many", "many2many"].includes(record.fields[fieldName].type)) { + if (activeFields[fieldName].related) { + const list = record.data[fieldName]; + const patch = { + activeFields: activeFields[fieldName].related.activeFields, + fields: activeFields[fieldName].related.fields, + }; + for (const subRecord of Object.values(list._cache)) { + this.model._updateConfig(subRecord.config, patch, { + noReload: true, + }); + } + this.model._updateConfig(list.config, patch, { noReload: true }); + } + } + } + record._applyValues(data); + record._addSavePoint(); + } else { + // case 2: the record is a new record + // -> simply create one with the extended config + record = await this._createNewRecordDatapoint({ + activeFields, + context: params.context, + withoutParent: params.withoutParent, + manuallyAdded: true, + }); + } + + // mark the record as being extended, to go through case 1.1 next time + this._extendedRecords.add(record.id); + + return record; + }); + } + + async forget(record) { + await this._applyCommands([[x2ManyCommands.FORGET, record.resId]]); + this._onUpdate(); + } + + leaveEditMode({ discard, canAbandon, validate } = {}) { + return this.model.mutex.exec(async () => { + if (this.editedRecord) { + await this.editedRecord._askChanges(); + const isValid = this.editedRecord._checkValidity(); + if (!isValid && validate) { + return false; + } + if (canAbandon !== false && !validate) { + this._abandonRecords([this.editedRecord], { force: true }); + } + // if we still have an editedRecord, it means it hasn't been abandonned + if (this.editedRecord) { + if (isValid && !this.editedRecord.dirty && discard) { + return false; + } + if ( + isValid || + (!this.editedRecord.dirty && !this.editedRecord._manuallyAdded) + ) { + this.editedRecord._switchMode("readonly"); + } + } + } + return !this.editedRecord; + }); + } + + async linkTo(resId, serverData) { + await this._applyCommands([[x2ManyCommands.LINK_TO, resId, serverData]]); + this._onUpdate(); + } + + load({ limit, offset, orderBy }) { + return this.model.mutex.exec(async () => { + if (this.editedRecord && !(await this.editedRecord.checkValidity())) { + return; + } + limit = limit !== undefined ? limit : this.limit; + offset = offset !== undefined ? offset : this.offset; + orderBy = orderBy !== undefined ? orderBy : this.orderBy; + return this._load({ limit, offset, orderBy }); + }); + } + + moveRecord(dataRecordId, _dataGroupId, refId, _targetGroupId) { + return this.resequence(dataRecordId, refId); + } + + sortBy(fieldName) { + return this.model.mutex.exec(() => this._sortBy(fieldName)); + } + + async replaceWith(ids, { reload = false, silent = false } = {}) { + const resIds = reload ? ids : ids.filter((id) => !this._cache[id]); + if (resIds.length) { + const records = await this.model._loadRecords({ + ...this.config, + resIds, + context: this.context, + }); + for (const record of records) { + this._createRecordDatapoint(record); + } + } + this.records = ids.map((id) => this._cache[id]); + const updateCommandsToKeep = this._commands.filter( + (c) => c[0] === x2ManyCommands.UPDATE && ids.includes(c[1]) + ); + this._commands = [x2ManyCommands.replaceWith(ids)].concat(updateCommandsToKeep); + this._currentIds = [...ids]; + this.count = this._currentIds.length; + if (!silent) { + this._onUpdate(); + } + } + + async resequence(movedId, targetId) { + return this.model.mutex.exec(() => this._resequence(movedId, targetId)); + } + + /** + * This method is meant to be called when a record, which has previously been extended to be + * displayed in a form view dialog (see @extendRecord) is saved. In this case, we may need to + * add this record to the list (if it is a new one), and to notify the parent record of the + * update. We may also want to sort the list. + * + * @param {Record} record + */ + validateExtendedRecord(record) { + return this.model.mutex.exec(async () => { + if (!this._currentIds.includes(record.isNew ? record._virtualId : record.resId)) { + // new record created, not yet in the list + await this._addRecord(record); + } + await this._onUpdate(); + if (this.orderBy.length) { + await this._sort(); + } + }); + } + + // ------------------------------------------------------------------------- + // Protected + // ------------------------------------------------------------------------- + + _abandonRecords(records = this.records, { force } = {}) { + for (const record of records) { + if (record.canBeAbandoned && (force || !record._checkValidity())) { + const virtualId = record._virtualId; + const index = this._currentIds.findIndex((id) => id === virtualId); + this._currentIds.splice(index, 1); + this.records.splice( + this.records.findIndex((r) => r === record), + 1 + ); + this._commands = this._commands.filter((c) => c[1] !== virtualId); + this.count--; + if (this._tmpIncreaseLimit > 0) { + this.model._updateConfig( + this.config, + { limit: this.limit - 1 }, + { noReload: true } + ); + this._tmpIncreaseLimit--; + } + } + } + } + + async _addRecord(record, { position } = {}) { + const command = [x2ManyCommands.CREATE, record._virtualId]; + if (position === "top") { + this.records.unshift(record); + if (this.records.length > this.limit) { + this.records.pop(); + } + this._currentIds.splice(this.offset, 0, record._virtualId); + this._commands.unshift(command); + } else if (position === "bottom") { + this.records.push(record); + this._currentIds.splice(this.offset + this.limit, 0, record._virtualId); + if (this.records.length > this.limit) { + this._tmpIncreaseLimit++; + const nextLimit = this.limit + 1; + this.model._updateConfig(this.config, { limit: nextLimit }, { noReload: true }); + } + this._commands.push(command); + } else { + const currentIds = [...this._currentIds, record._virtualId]; + if (this.orderBy.length) { + await this._sort(currentIds); + } else { + if (this.records.length < this.limit) { + this.records.push(record); + } + } + this._currentIds = currentIds; + this._commands.push(command); + } + this.count++; + this._needsReordering = true; + } + + _addSavePoint() { + for (const id in this._cache) { + this._cache[id]._addSavePoint(); + } + this._savePoint._commands = [...this._commands]; + this._savePoint._currentIds = [...this._currentIds]; + this._savePoint.count = this.count; + } + + _applyCommands(commands) { + const isOnLastPage = this.limit + this.offset >= this.count; + const { CREATE, UPDATE, DELETE, FORGET, LINK_TO, REPLACE_WITH } = x2ManyCommands; + for (const command of commands) { + switch (command[0]) { + case CREATE: { + const virtualId = getId("virtual"); + const record = this._createRecordDatapoint(command[2], { virtualId }); + this.records.push(record); + this._commands.push([CREATE, virtualId]); + this._currentIds.splice(this.offset + this.limit, 0, virtualId); + this.count++; + break; + } + case UPDATE: { + const existingCommand = this._commands.find((c) => { + return (c[0] === CREATE || c[0] === UPDATE) && c[1] === command[1]; + }); + if (!existingCommand) { + this._commands.push([UPDATE, command[1]]); + } + const record = this._cache[command[1]]; + if (!record) { + // the record isn't in the cache, it means it is on a page we haven't loaded + // so we say the record is "unknown", and store all update commands we + // receive about it in a separated structure, s.t. we can easily apply them + // later on after loading the record, if we ever load it. + if (!(command[1] in this._unknownRecordCommands)) { + this._unknownRecordCommands[command[1]] = []; + } + this._unknownRecordCommands[command[1]].push(command); + } else if (command[1] in this._unknownRecordCommands) { + // this case is more tricky: the record is in the cache, but it isn't loaded + // yet, as we are currently loading it (see below, where we load missing + // records for the current page) + this._unknownRecordCommands[command[1]].push(command); + } else { + record._applyChanges(record._parseServerValues(command[2], record.data)); + } + break; + } + case DELETE: + case FORGET: { + if (command[0] === DELETE) { + if (!this._commands.find((c) => c[0] === CREATE && c[1] === command[1])) { + this._commands.push([DELETE, command[1]]); + } + this._commands = this._commands.filter((c) => { + return !(c[0] === CREATE || c[0] === UPDATE) || c[1] !== command[1]; + }); + } else { + // FORGET + const replaceWithIndex = this._commands.findIndex( + (c) => c[0] === REPLACE_WITH && c[2].includes(command[1]) + ); + if (replaceWithIndex >= 0) { + const ids = this._commands[replaceWithIndex][2]; + this._commands[replaceWithIndex][2] = ids.filter( + (id) => id !== command[1] + ); + } else { + const linkToIndex = this._commands.findIndex( + (c) => c[0] === LINK_TO && c[1] === command[1] + ); + if (linkToIndex >= 0) { + this._commands.splice(linkToIndex, 1); + } else { + this._commands.push([FORGET, command[1]]); + } + } + } + const record = this._cache[command[1]]; + this.records.splice( + this.records.findIndex((r) => r.id === record.id), + 1 + ); + if (record.resId) { + const index = this._currentIds.findIndex((id) => id === record.resId); + this._currentIds.splice(index, 1); + } + this.count--; + break; + } + case LINK_TO: { + const record = this._createRecordDatapoint({ ...command[2], id: command[1] }); + if (!this.limit || this.records.length < this.limit) { + this.records.push(record); + } + this._currentIds.push(record.resId); + this._commands.push([command[0], command[1]]); + this.count++; + break; + } + } + } + // if we aren't on the last page, and *n* records of the current page have been removed + // removed, the first *n* records of the next page become the last *n* ones of the current + // page, so we need to add (and maybe load) them. + const nbMissingRecords = this.limit - this.records.length; + if (!isOnLastPage && nbMissingRecords > 0) { + const lastRecordIndex = this.limit + this.offset; + const firstRecordIndex = lastRecordIndex - nbMissingRecords; + const nextRecordIds = this._currentIds.slice(firstRecordIndex, lastRecordIndex); + const recordsToLoad = []; + for (const id of nextRecordIds) { + if (this._cache[id]) { + this.records.push(this._cache[id]); + } else { + // id isn't in the cache, so we know it's not a virtual id + const record = this._createRecordDatapoint({ id }, { dontApplyCommands: true }); + this.records.push(record); + recordsToLoad.push(record); + } + } + const resIds = recordsToLoad.map((r) => r.resId); + this.model._loadRecords({ ...this.config, resIds }).then((recordValues) => { + for (let i = 0; i < recordsToLoad.length; i++) { + const record = recordsToLoad[i]; + record._applyValues(recordValues[i]); + const commands = this._unknownRecordCommands[record.resId]; + if (commands) { + delete this._unknownRecordCommands[record.resId]; + this._applyCommands(commands); + } + } + }); + } + } + + async _createNewRecordDatapoint(params = {}) { + const changes = {}; + if (!params.withoutParent) { + changes[this.config.relationField] = this._parent._getChanges(); + } + const values = await this.model._loadNewRecord( + { + resModel: this.resModel, + activeFields: params.activeFields || this.activeFields, + fields: this.fields, + context: Object.assign({}, this.context, params.context), + }, + { changes, evalContext: this.evalContext } + ); + + if (this.canResequence() && this.records.length) { + const position = params.position || "bottom"; + const order = this.orderBy[0]; + const asc = !order || order.asc; + let value; + if (position === "top") { + const isOnFirstPage = this.offset === 0; + value = this.records[0].data[this.handleField]; + if (isOnFirstPage) { + if (asc) { + value = value > 0 ? value - 1 : 0; + } else { + value = value + 1; + } + } + } else if (position === "bottom") { + value = this.records[this.records.length - 1].data[this.handleField]; + const isOnLastPage = this.limit + this.offset >= this.count; + if (isOnLastPage) { + if (asc) { + value = value + 1; + } else { + value = value > 0 ? value - 1 : 0; + } + } + } + values[this.handleField] = value; + } + return this._createRecordDatapoint(values, { + mode: params.mode || "edit", + virtualId: getId("virtual"), + activeFields: params.activeFields, + manuallyAdded: params.manuallyAdded, + }); + } + + _createRecordDatapoint(data, params = {}) { + const resId = data.id || false; + if (!resId && !params.virtualId) { + throw new Error("You must provide a virtualId if the record has no id"); + } + const id = resId || params.virtualId; + const config = { + context: this.context, + activeFields: params.activeFields || this.activeFields, + resModel: this.resModel, + fields: params.fields || this.fields, + relationField: this.config.relationField, + resId, + resIds: resId ? [resId] : [], + mode: params.mode || "readonly", + isMonoRecord: true, + }; + const { CREATE, UPDATE } = x2ManyCommands; + const options = { + parentRecord: this._parent, + onUpdate: async (changes, { withoutParentUpdate }) => { + if (!this.currentIds.includes(record.isNew ? record._virtualId : record.resId)) { + // the record hasn't been added to the list yet (we're currently creating it + // from a dialog) + return; + } + const hasCommand = this._commands.some( + (c) => (c[0] === CREATE || c[0] === UPDATE) && c[1] === id + ); + if (!hasCommand) { + this._commands.push([UPDATE, id]); + } + if (this._extendedRecords.has(record.id)) { + // the record is edited from a dialog, so we don't want to notify the parent + // record to be notified at each change inside the dialog (it will be notified + // at the end when the dialog is saved) + return; + } + if (!withoutParentUpdate) { + await this._onUpdate({ + withoutOnchange: !record._checkValidity({ silent: true }), + }); + } + }, + virtualId: params.virtualId, + manuallyAdded: params.manuallyAdded, + }; + const record = new this.model.constructor.Record(this.model, config, data, options); + this._cache[id] = record; + if (!params.dontApplyCommands) { + const commands = this._unknownRecordCommands[id]; + if (commands) { + delete this._unknownRecordCommands[id]; + this._applyCommands(commands); + } + } + return record; + } + + _discard() { + for (const id in this._cache) { + this._cache[id]._discard(); + } + if (this._savePoint._commands) { + this._commands = this._savePoint._commands; + this._currentIds = this._savePoint._currentIds; + this.count = this._savePoint.count; + delete this._savePoint._commands; + delete this._savePoint._currentIds; + delete this._savePoint.count; + } else { + this._commands = []; + this._currentIds = [...this.resIds]; + this.count = this.resIds.length; + } + this._unknownRecordCommands = []; + const limit = this.limit - this._tmpIncreaseLimit; + this._tmpIncreaseLimit = 0; + this.model._updateConfig(this.config, { limit }, { noReload: true }); + this.records = this._currentIds + .slice(this.offset, this.limit) + .map((resId) => this._cache[resId]); + } + + _getCommands({ withReadonly } = {}) { + const { CREATE, UPDATE } = x2ManyCommands; + const commands = []; + for (const command of this._commands) { + if (command[0] === UPDATE && command[1] in this._unknownRecordCommands) { + // the record has never been loaded, but we received update commands from the + // server for it, so we need to sanitize them (as they contained unity values) + const uCommands = this._unknownRecordCommands[command[1]]; + for (const uCommand of uCommands) { + const values = fromUnityToServerValues( + uCommand[2], + this.fields, + this.activeFields + ); + commands.push([uCommand[0], uCommand[1], values]); + } + } else if (command[0] === CREATE || command[0] === UPDATE) { + const record = this._cache[command[1]]; + const values = record._getChanges(record._changes, { withReadonly }); + if (command[0] === CREATE || Object.keys(values).length) { + commands.push([command[0], command[1], values]); + } + } else { + commands.push(command); + } + } + return commands; + } + + async _load({ limit, offset, orderBy }) { + const currentIds = this._currentIds.slice(offset, offset + limit); + // FIXME: record in cache could be not yet loaded + const resIds = currentIds.filter((id) => typeof id === "number" && !this._cache[id]); + if (resIds.length) { + const records = await this.model._loadRecords({ ...this.config, resIds }); + for (const record of records) { + this._createRecordDatapoint(record); + } + } + this.records = currentIds.map((id) => this._cache[id]); + await this.model._updateConfig(this.config, { limit, offset, orderBy }, { noReload: true }); + } + + async _resequence(movedId, targetId) { + const records = [...this.records]; + const order = this.orderBy.find((o) => o.name === this.handleField); + const asc = !order || order.asc; + + // Find indices + const fromIndex = records.findIndex((r) => r.id === movedId); + let toIndex = 0; + if (targetId !== null) { + const targetIndex = records.findIndex((r) => r.id === targetId); + toIndex = fromIndex > targetIndex ? targetIndex + 1 : targetIndex; + } + + const getSequence = (rec) => rec && rec.data[this.handleField]; + + // Determine what records need to be modified + const firstIndex = Math.min(fromIndex, toIndex); + const lastIndex = Math.max(fromIndex, toIndex) + 1; + let reorderAll = false; + let lastSequence = (asc ? -1 : 1) * Infinity; + for (let index = 0; index < records.length; index++) { + const sequence = getSequence(records[index]); + if ( + ((index < firstIndex || index >= lastIndex) && + ((asc && lastSequence >= sequence) || (!asc && lastSequence <= sequence))) || + (index >= firstIndex && index < lastIndex && lastSequence === sequence) + ) { + reorderAll = true; + } + lastSequence = sequence; + } + + // Perform the resequence in the list of records + const [record] = records.splice(fromIndex, 1); + records.splice(toIndex, 0, record); + + // Creates the list of to modify + let toReorder = records; + if (!reorderAll) { + toReorder = toReorder.slice(firstIndex, lastIndex).filter((r) => r.id !== movedId); + if (fromIndex < toIndex) { + toReorder.push(record); + } else { + toReorder.unshift(record); + } + } + if (!asc) { + toReorder.reverse(); + } + + const sequences = toReorder.map(getSequence); + const offset = sequences.length && Math.min(...sequences); + + const proms = []; + for (const [i, record] of Object.entries(toReorder)) { + proms.push( + record._update( + { [this.handleField]: offset + Number(i) }, + { withoutParentUpdate: true } + ) + ); + } + await Promise.all(proms); + + await this._sort(); + this._onUpdate(); + } + + async _sort(currentIds = this.currentIds, orderBy = this.orderBy) { + const fieldNames = orderBy.map((o) => o.name); + const resIds = currentIds.filter((id) => { + if (typeof id === "string") { + // this is a virtual id, we don't want to read it + return false; + } + const record = this._cache[id]; + if (!record) { + // record hasn't been loaded yet + return true; + } + // record has already been loaded -> check if we already read all orderBy fields + return intersection(record.fieldNames, fieldNames).length !== fieldNames.length; + }); + if (resIds.length) { + const activeFields = pick(this.activeFields, fieldNames); + const config = { ...this.config, resIds, activeFields }; + const records = await this.model._loadRecords(config); + for (const record of records) { + this._createRecordDatapoint(record, { activeFields }); + } + } + const allRecords = currentIds.map((id) => this._cache[id]); + const sortedRecords = allRecords.sort((r1, r2) => { + return compareRecords(r1, r2, orderBy, this.fields); + }); + const currentPageRecords = sortedRecords.slice(this.offset, this.offset + this.limit); + //TODO: read records that haven't been fully read yet + this.model._updateConfig(this.config, { orderBy }, { noReload: true }); + this.records = currentPageRecords; + this._needsReordering = false; + } + + async _sortBy(fieldName) { + let orderBy = [...this.orderBy]; + if (fieldName) { + if (orderBy.length && orderBy[0].name === fieldName) { + if (!this._needsReordering) { + orderBy[0] = { name: orderBy[0].name, asc: !orderBy[0].asc }; + } + } else { + orderBy = orderBy.filter((o) => o.name !== fieldName); + orderBy.unshift({ + name: fieldName, + asc: true, + }); + } + } + return this._sort(this._currentIds, orderBy); + } + + _updateContext(context) { + Object.assign(this.context, context); + } +} diff --git a/addons/web/static/src/model/relational_model/utils.js b/addons/web/static/src/model/relational_model/utils.js new file mode 100644 index 00000000000..26f6a6e832f --- /dev/null +++ b/addons/web/static/src/model/relational_model/utils.js @@ -0,0 +1,523 @@ +/* @odoo-module */ + +import { markup, onWillDestroy, onWillStart, onWillUpdateProps, useComponent } from "@odoo/owl"; +import { makeContext } from "@web/core/context"; +import { deserializeDate, deserializeDateTime } from "@web/core/l10n/dates"; +import { x2ManyCommands } from "@web/core/orm_service"; +import { Deferred } from "@web/core/utils/concurrency"; +import { omit } from "@web/core/utils/objects"; +import { effect } from "@web/core/utils/reactive"; +import { batched } from "@web/core/utils/timing"; +import { orderByToString } from "@web/search/utils/order_by"; + +export function makeActiveField({ + context, + invisible, + readonly, + required, + onChange, + forceSave, + isHandle, +} = {}) { + return { + context: context || "{}", + invisible: invisible || false, + readonly: readonly || false, + required: required || false, + onChange: onChange || false, + forceSave: forceSave || false, + isHandle: isHandle || false, + }; +} + +const AGGREGATABLE_FIELD_TYPES = ["float", "integer", "monetary"]; // types that can be aggregated in grouped views + +export function addFieldDependencies(activeFields, fields, fieldDependencies = []) { + for (const field of fieldDependencies) { + if (field.name in activeFields) { + patchActiveFields(activeFields[field.name], field); + } else { + activeFields[field.name] = makeActiveField(field); + } + if (!fields[field.name]) { + fields[field.name] = omit(field, [ + "context", + "invisible", + "required", + "readonly", + "onChange", + ]); + } + } +} + +export function createPropertyActiveField(property) { + const { type } = property; + + const activeField = makeActiveField(); + if (type === "many2many") { + activeField.related = { + fields: { + id: { name: "id", type: "integer" }, + display_name: { name: "display_name", type: "char" }, + }, + activeFields: { + id: makeActiveField({ readonly: true }), + display_name: makeActiveField(), + }, + }; + } + return activeField; +} + +export function patchActiveFields(activeField, patch) { + activeField.invisible = activeField.invisible && patch.invisible; + activeField.readonly = activeField.readonly && patch.readonly; + activeField.required = activeField.required || patch.required; + activeField.onChange = activeField.onChange || patch.onChange; + activeField.forceSave = activeField.forceSave || patch.forceSave; + activeField.isHandle = activeField.isHandle || patch.isHandle; + // x2manys + if (patch.related) { + const related = activeField.related; + if (related) { + for (const fieldName in patch.related.activeFields) { + if (fieldName in related.activeFields) { + patchActiveFields( + related.activeFields[fieldName], + patch.related.activeFields[fieldName] + ); + } else { + related.activeFields[fieldName] = { ...patch.related.activeFields[fieldName] }; + } + } + Object.assign(related.fields, patch.related.fields); + } else { + activeField.related = patch.related; + } + } + if ("limit" in patch) { + activeField.limit = patch.limit; + } + if ("defaultOrderBy" in patch) { + activeField.defaultOrderBy = patch.defaultOrderBy; + } +} + +export function extractFieldsFromArchInfo({ fieldNodes, widgetNodes }, fields) { + const activeFields = {}; + for (const fieldNode of Object.values(fieldNodes)) { + const fieldName = fieldNode.name; + const modifiers = fieldNode.modifiers || {}; + const activeField = makeActiveField({ + context: fieldNode.context, + invisible: modifiers.invisible || modifiers.column_invisible, + readonly: modifiers.readonly, + required: modifiers.required, + onChange: fieldNode.onChange, + forceSave: fieldNode.forceSave, + isHandle: fieldNode.isHandle, + }); + if (["one2many", "many2many"].includes(fields[fieldName].type)) { + if (fieldNode.views) { + const viewDescr = fieldNode.views[fieldNode.viewMode]; + if (viewDescr) { + activeField.related = extractFieldsFromArchInfo(viewDescr, viewDescr.fields); + activeField.limit = viewDescr.limit; + activeField.defaultOrderBy = viewDescr.defaultOrder; + if (fieldNode.views.form) { + // we already know the form view (it is inline), so add its fields (in invisible) + // s.t. they will be sent in the spec for onchange, and create commands returned + // by the onchange could return values for those fields (that would be displayed + // later if the user opens the form view) + const formArchInfo = extractFieldsFromArchInfo( + fieldNode.views.form, + fieldNode.views.form.fields + ); + for (const fieldName in formArchInfo.activeFields) { + const formActiveField = { + ...formArchInfo.activeFields[fieldName], + invisible: true, + }; + if (fieldName in activeField.related.activeFields) { + patchActiveFields( + formActiveField, + activeField.related.activeFields[fieldName] + ); + } + activeField.related.activeFields[fieldName] = formActiveField; + } + Object.assign(activeField.related.fields, formArchInfo.fields); + } + } + } + if (fieldNode.field?.useSubView) { + activeField.required = false; + } + } + + if (fieldName in activeFields) { + patchActiveFields(activeFields[fieldName], activeField); + } else { + activeFields[fieldName] = activeField; + } + + if (fieldNode.field) { + let fieldDependencies = fieldNode.field.fieldDependencies; + if (typeof fieldDependencies === "function") { + fieldDependencies = fieldDependencies(fieldNode); + } + addFieldDependencies(activeFields, fields, fieldDependencies); + } + } + + for (const widgetInfo of Object.values(widgetNodes || {})) { + let fieldDependencies = widgetInfo.widget.fieldDependencies; + if (typeof fieldDependencies === "function") { + fieldDependencies = fieldDependencies(widgetInfo); + } + addFieldDependencies(activeFields, fields, fieldDependencies); + } + return { activeFields, fields }; +} + +export function getFieldContext( + record, + fieldName, + rawContext = record.activeFields[fieldName].context +) { + const context = {}; + for (const key in record.context) { + if ( + !key.startsWith("default_") && + !key.startsWith("search_default_") && + !key.endsWith("_view_ref") + ) { + context[key] = record.context[key]; + } + } + + return { + ...context, + ...record.fields[fieldName].context, + ...makeContext([rawContext], record.evalContext), + }; +} + +const SENTINEL = Symbol("sentinel"); +function _getFieldContextSpec( + fieldName, + activeFields, + fields, + evalContext, + parentActiveFields = null +) { + const rawContext = activeFields[fieldName].context; + if (!rawContext || rawContext === "{}") { + return fields[fieldName].context; + } + + evalContext = { + ids: SENTINEL, + active_id: SENTINEL, + active_ids: SENTINEL, + active_model: SENTINEL, + ...evalContext, + id: SENTINEL, + }; + for (const fieldName in activeFields) { + evalContext[fieldName] = SENTINEL; + } + if (parentActiveFields) { + evalContext.parent = {}; + for (const fieldName in parentActiveFields) { + evalContext.parent[fieldName] = SENTINEL; + } + } + const evaluatedContext = makeContext([fields[fieldName].context, rawContext], evalContext); + for (const key in evaluatedContext) { + if (evaluatedContext[key] === SENTINEL || key.startsWith("default_")) { + // FIXME: this isn't perfect, a value might be evaluted to something else + // than the symbol because of the symbol + delete evaluatedContext[key]; + } + } + if (Object.keys(evaluatedContext).length > 0) { + return evaluatedContext; + } +} + +export function getFieldsSpec( + activeFields, + fields, + evalContext, + { parentActiveFields, withInvisible } = {} +) { + const fieldsSpec = {}; + const properties = []; + for (const fieldName in activeFields) { + if (fields[fieldName].relatedPropertyField) { + continue; + } + const { related, limit, defaultOrderBy, invisible } = activeFields[fieldName]; + fieldsSpec[fieldName] = {}; + // X2M + if (related && (invisible !== true || withInvisible)) { + fieldsSpec[fieldName].fields = getFieldsSpec( + related.activeFields, + related.fields, + evalContext, + { parentActiveFields: activeFields, withInvisible } + ); + fieldsSpec[fieldName].limit = limit; + if (defaultOrderBy) { + fieldsSpec[fieldName].order = orderByToString(defaultOrderBy); + } + } + // Properties + if (fields[fieldName].type === "properties") { + properties.push(fieldName); + } + // M2O + if (fields[fieldName].type === "many2one" && invisible !== true) { + fieldsSpec[fieldName].fields = { display_name: {} }; + } + if (["many2one", "one2many", "many2many"].includes(fields[fieldName].type)) { + const context = _getFieldContextSpec( + fieldName, + activeFields, + fields, + evalContext, + parentActiveFields + ); + if (context) { + fieldsSpec[fieldName].context = context; + } + } + // Reference + if (fields[fieldName].type === "reference") { + fieldsSpec[fieldName].fields = { display_name: {} }; + } + } + + for (const fieldName of properties) { + const fieldSpec = fieldsSpec[fields[fieldName].definition_record]; + if (fieldSpec) { + if (!fieldSpec.fields) { + fieldSpec.fields = {}; + } + fieldSpec.fields.display_name = {}; + } + } + return fieldsSpec; +} + +let nextId = 0; +/** + * @param {string} [prefix] + * @returns {string} + */ +export function getId(prefix = "") { + return `${prefix}_${++nextId}`; +} + +/** + * @protected + * @param {Field | false} field + * @param {any} value + * @returns {any} + */ +export function parseServerValue(field, value) { + switch (field.type) { + case "char": + case "text": { + return value || ""; + } + case "date": { + return value ? deserializeDate(value) : false; + } + case "datetime": { + return value ? deserializeDateTime(value) : false; + } + case "html": { + return markup(value || ""); + } + case "selection": { + if (value === false) { + // process selection: convert false to 0, if 0 is a valid key + const hasKey0 = field.selection.find((option) => option[0] === 0); + return hasKey0 ? 0 : value; + } + return value; + } + case "reference": { + if (value === false) { + return false; + } + return { + resId: value.id.id, + resModel: value.id.model, + displayName: value.display_name, + }; + } + case "many2one": { + if (Array.isArray(value)) { + return value; + } + if (Number.isInteger(value)) { + // for always invisible many2ones, unity directly returns the id, not a pair + return [value, ""]; + } + return value ? [value.id, value.display_name] : false; + } + case "properties": { + return value + ? value.map((property) => ({ + ...property, + value: parseServerValue(property, property.value ?? false), + })) + : []; + } + } + return value; +} + +/** + * @param {Object} groupData + * @returns {Object} + */ +export function getAggregatesFromGroupData(groupData, fields) { + const aggregates = {}; + for (const [key, value] of Object.entries(groupData)) { + if (key in fields && AGGREGATABLE_FIELD_TYPES.includes(fields[key].type)) { + aggregates[key] = value; + } + } + return aggregates; +} + +/** + * @param {import("./datapoint").Field} field + * @param {any} rawValue + * @returns {string | false} + */ +export function getDisplayNameFromGroupData(field, rawValue) { + if (field.type === "selection") { + return Object.fromEntries(field.selection)[rawValue]; + } + if (["many2one", "many2many"].includes(field.type)) { + return rawValue ? rawValue[1] : false; + } + return rawValue; +} + +/** + * @param {Object} groupData + * @param {import("./datapoint").Field} field + * @param {any} rawValue + * @returns {any} + */ +export function getValueFromGroupData(groupData, field, rawValue) { + if (["date", "datetime"].includes(field.type)) { + const range = groupData.range; + if (!range) { + return false; + } + const dateValue = parseServerValue(field, range.to); + return dateValue.minus({ + [field.type === "date" ? "day" : "second"]: 1, + }); + } + const value = parseServerValue(field, rawValue); + if (["many2one", "many2many"].includes(field.type)) { + return value ? value[0] : false; + } + return value; +} + +/** + * Onchanges sometimes return update commands for records we don't know (e.g. if + * they are on a page we haven't loaded yet). We may actually never load them. + * When this happens, we must still be able to send back those commands to the + * server when saving. However, we can't send the commands exactly as we received + * them, since the values they contain have been "unity read". The purpose of this + * function is to transform field values from the unity format to the format + * expected by the server for a write. + * For instance, for a many2one: { id: 3, display_name: "Marc" } => 3. + */ +export function fromUnityToServerValues(values, fields, activeFields) { + const { CREATE, UPDATE } = x2ManyCommands; + const serverValues = {}; + for (const fieldName in values) { + let value = values[fieldName]; + switch (fields[fieldName].type) { + case "one2many": + case "many2many": + value = value.map((c) => { + if (c[0] === CREATE || c[0] === UPDATE) { + const _fields = activeFields[fieldName].related.fields; + const _activeFields = activeFields[fieldName].related.activeFields; + return [c[0], c[1], fromUnityToServerValues(c[2], _fields, _activeFields)]; + } + return [c[0], c[1]]; + }); + break; + case "many2one": + value = value ? value.id : false; + break; + // case "reference": + // // TODO + // break; + } + serverValues[fieldName] = value; + } + return serverValues; +} + +/** + * @param {any} field + * @returns {boolean} + */ +export function isRelational(field) { + return field && ["one2many", "many2many", "many2one"].includes(field.type); +} + +/** + * This hook should only be used in a component field because it + * depends on the record props. + * The callback will be executed once during setup and each time + * a record value read in the callback changes. + * @param {(record) => void} callback + */ +export function useRecordObserver(callback) { + const component = useComponent(); + let alive = true; + const fct = (props) => { + const def = new Deferred(); + effect( + batched( + async (record) => { + if (!alive) { + // effect doesn't clean up when the component is unmounted. + // We must do it manually. + return; + } + await callback(record); + def.resolve(); + }, + () => new Promise((resolve) => window.requestAnimationFrame(resolve)) + ), + [props.record] + ); + return def; + }; + onWillDestroy(() => { + alive = false; + }); + onWillStart(() => fct(component.props)); + onWillUpdateProps((props) => { + if (props.record.id !== component.props.record.id) { + return fct(props); + } + }); +} diff --git a/addons/web/static/src/views/sample_server.js b/addons/web/static/src/model/sample_server.js similarity index 90% rename from addons/web/static/src/views/sample_server.js rename to addons/web/static/src/model/sample_server.js index 847bc37bc64..4f1b0110385 100644 --- a/addons/web/static/src/views/sample_server.js +++ b/addons/web/static/src/model/sample_server.js @@ -10,9 +10,12 @@ import { import { ORM } from "@web/core/orm_service"; import { registry } from "@web/core/registry"; import { cartesian, sortBy as arraySortBy } from "@web/core/utils/arrays"; +import { parseServerValue } from "./relational_model/utils"; class UnimplementedRouteError extends Error {} +let searchReadNumber = 0; + /** * Helper function returning the value from a list of sample strings * corresponding to the given ID. @@ -24,15 +27,15 @@ function getSampleFromId(id, sampleTexts) { return sampleTexts[(id - 1) % sampleTexts.length]; } -function serializeGroupValue(value, type) { - switch (type) { - case "date": - return serializeDate(value); - case "datetime": - return serializeDateTime(value); - default: - return value; +function serializeGroupDateValue(range, field) { + if (!range) { + return false; } + let dateValue = parseServerValue(field, range.to); + dateValue = dateValue.minus({ + [field.type === "date" ? "day" : "second"]: 1, + }); + return field.type === "date" ? serializeDate(dateValue) : serializeDateTime(dateValue); } /** @@ -126,6 +129,8 @@ export class SampleServer { return this._mockSearchReadController(params); case "web_search_read": return this._mockWebSearchRead(params); + case "unity_web_search_read": + return this._mockWebSearchReadUnity(params); case "web_read_group": return this._mockWebReadGroup(params); case "read_group": @@ -632,6 +637,43 @@ export class SampleServer { return { records, length: records.length }; } + _mockWebSearchReadUnity(params) { + const fields = Object.keys(params.specification); + let result; + if (this.existingGroups) { + const groups = this.existingGroups; + const group = groups[searchReadNumber++ % groups.length]; + result = { + records: group.__data.records, + length: group.__data.records.length, + }; + } else { + result = this._mockWebSearchRead({ ...params, fields }); + } + // populate x2many values + for (const fieldName in params.specification) { + const field = this.data[params.model].fields[fieldName]; + if (field.type === "one2many" || field.type === "many2many") { + const relFields = Object.keys(params.specification[fieldName].fields || {}); + if (relFields.length) { + const relIds = result.records.map((r) => r[fieldName]).flat(); + const relRecords = {}; + const _relRecords = this._mockRead({ + model: field.relation, + args: [relIds, relFields], + }); + for (const relRecord of _relRecords) { + relRecords[relRecord.id] = relRecord; + } + for (const record of result.records) { + record[fieldName] = record[fieldName].map((resId) => relRecords[resId]); + } + } + } + } + return result; + } + /** * Mocks calls to the web_read_group method to return groups populated * with sample records. Only handles the case where the real call to @@ -668,22 +710,26 @@ export class SampleServer { * @param {string[]} params.groupBy */ _populateExistingGroups(params) { - if (!this.existingGroupsPopulated) { - const groups = this.existingGroups; - const groupBy = params.groupBy[0].split(":")[0]; - const groupByField = this.data[params.model].fields[groupBy]; - const groupedByM2O = groupByField.type === "many2one"; - if (groupedByM2O) { - // re-populate co model with relevant records - this.data[groupByField.relation].records = groups.map((g) => { - return { id: g.value, display_name: g.displayName }; - }); + const groups = this.existingGroups; + const groupBy = params.groupBy[0].split(":")[0]; + const groupByField = this.data[params.model].fields[groupBy]; + const groupedByM2O = groupByField.type === "many2one"; + if (groupedByM2O) { + // re-populate co model with relevant records + this.data[groupByField.relation].records = groups.map((g) => { + return { id: g.value, display_name: g.displayName }; + }); + } + for (const r of this.data[params.model].records) { + const group = getSampleFromId(r.id, groups); + if (["date", "datetime"].includes(groupByField.type)) { + r[groupBy] = serializeGroupDateValue( + group.__range[params.groupBy[0]], + groupByField + ); + } else { + r[groupBy] = group[params.groupBy[0]]; } - for (const r of this.data[params.model].records) { - const group = getSampleFromId(r.id, groups); - r[groupBy] = serializeGroupValue(group.value, groupByField.type); - } - this.existingGroupsPopulated = true; } } @@ -748,16 +794,20 @@ export class SampleServer { const records = this.data[params.model].records; for (const g of groups) { const recordsInGroup = records.filter((r) => { - return r[groupBy] === serializeGroupValue(g.value, groupByField.type); + if (["date", "datetime"].includes(groupByField.type)) { + return ( + r[groupBy] === serializeGroupDateValue(g.__range[fullGroupBy], groupByField) + ); + } + return r[groupBy] === g[fullGroupBy]; }); - g[`${groupBy}_count`] = recordsInGroup.length; - g[fullGroupBy] = g.__rawValue; for (const field of params.fields) { const fieldType = this.data[params.model].fields[field].type; if (["integer, float", "monetary"].includes(fieldType)) { g[field] = recordsInGroup.reduce((acc, r) => acc + r[field], 0); } } + g[`${groupBy}_count`] = recordsInGroup.length; g.__data = { records: this._mockRead({ model: params.model, @@ -765,7 +815,6 @@ export class SampleServer { }), length: recordsInGroup.length, }; - g.__range = { ...g.range }; } } } diff --git a/addons/web/static/src/search/search_model.js b/addons/web/static/src/search/search_model.js index 3d631511688..70312c53549 100644 --- a/addons/web/static/src/search/search_model.js +++ b/addons/web/static/src/search/search_model.js @@ -26,7 +26,7 @@ import { useGetDomainTreeDescription } from "@web/core/domain_selector/utils"; const { DateTime } = luxon; /** @typedef {import("@web/core/domain").DomainRepr} DomainRepr */ -/** @typedef {import("../views/relational_model").OrderTerm} OrderTerm */ +/** @typedef {import("../views/utils").OrderTerm} OrderTerm */ /** * @typedef {Object} ComparisonDomain diff --git a/addons/web/static/src/search/utils/order_by.js b/addons/web/static/src/search/utils/order_by.js new file mode 100644 index 00000000000..d908dadf2fc --- /dev/null +++ b/addons/web/static/src/search/utils/order_by.js @@ -0,0 +1,39 @@ +/** @odoo-module */ + +/** + * @typedef {Object} OrderTerm + * @property {string} name + * @property {boolean} asc + */ + +/** + * @param {OrderTerm[]} orderBy + * @returns {string} + */ +export function orderByToString(orderBy) { + return orderBy.map((o) => `${o.name} ${o.asc ? "ASC" : "DESC"}`).join(", "); +} + +/** + * @param {any} string + * @return {OrderTerm[]} + */ +export function stringToOrderBy(string) { + if (!string) { + return []; + } + return string.split(",").map((order) => { + const splitOrder = order.trim().split(" "); + if (splitOrder.length === 2) { + return { + name: splitOrder[0], + asc: splitOrder[1].toLowerCase() === "asc", + }; + } else { + return { + name: splitOrder[0], + asc: true, + }; + } + }); +} diff --git a/addons/web/static/src/views/basic_relational_model.js b/addons/web/static/src/views/basic_relational_model.js deleted file mode 100644 index f577c35111c..00000000000 --- a/addons/web/static/src/views/basic_relational_model.js +++ /dev/null @@ -1,1445 +0,0 @@ -/* @odoo-module */ - -import { makeContext } from "@web/core/context"; -import { Domain } from "@web/core/domain"; -import { WarningDialog } from "@web/core/errors/error_dialogs"; -import { deserializeDate, deserializeDateTime } from "@web/core/l10n/dates"; -import { KeepLast } from "@web/core/utils/concurrency"; -import { escape } from "@web/core/utils/strings"; -import { mapDoActionOptionAPI } from "@web/legacy/backend_utils"; -import { - mapActiveFieldsToFieldsInfo, - mapViews, - mapWowlValueToLegacy, -} from "@web/views/legacy_utils"; -import { Model } from "@web/views/model"; -import { evalDomain } from "@web/views/utils"; -import BasicModel from "web.BasicModel"; -import Context from "web.Context"; - -import { markup, toRaw } from "@odoo/owl"; - -const DEFAULT_HANDLE_FIELD = "sequence"; - -let nextId = 0; -class DataPoint { - /** - * @param {RelationalModel} model - * @param {Object} [params={}] - * @param {Object} [state={}] - */ - constructor(model, params, state = {}) { - this.id = `datapoint_${nextId++}`; - this.model = model; - - let info; - if (params.__bm_load_params__) { - this.__bm_load_params__ = params.__bm_load_params__; - info = this.__bm_load_params__; - this.context = info.context; - } else if (params.handle) { - this.__bm_handle__ = params.handle; - info = this.model.__bm__.get(this.__bm_handle__); - this.context = this.model.__bm__.localData[this.__bm_handle__].getContext(); - } else { - throw new Error("Datapoint needs load params or handle"); - } - this.resModel = info.model || info.modelName; - this.fields = info.fields; - this.activeFields = {}; - - this.__syncParent = params.__syncParent || (() => {}); - this.__viewType = params.viewType || info.viewType; - const fieldsInfo = (info.fieldsInfo && info.fieldsInfo[this.__viewType]) || {}; - for (const [name, descr] of Object.entries(fieldsInfo)) { - this.activeFields[name] = descr.__WOWL_FIELD_DESCR__ || {}; - } - - this.setup(params, state); - } - - /** - * @abstract - * @param {Object} params - * @param {Object} state - */ - setup() {} - - get evalContext() { - const datapoint = this.model.__bm__.localData[this.__bm_handle__]; - return this.model.__bm__._getLazyEvalContext(datapoint); - } - - getEvalContext(forDomain) { - const datapoint = this.model.__bm__.localData[this.__bm_handle__]; - return this.model.__bm__._getEvalContext(datapoint, forDomain, false, true); - } - - get fieldNames() { - return Object.keys(this.activeFields); - } - - exportState() { - return {}; - } - - async load() { - throw new Error("load must be implemented"); - } -} - -export class Record extends DataPoint { - setup(params, state) { - this.data = {}; - this._invalidFields = new Set(); - this.preloadedData = {}; - this.selected = false; - this.isInQuickCreation = params.isInQuickCreation || false; - this._onChangePromise = Promise.resolve({}); - this._savePromise = Promise.resolve(); - this._domains = {}; - this._closeInvalidFieldsNotification = () => {}; - - this.onWillSaveRecord = params.onWillSaveRecord || (() => {}); - this.onRecordSaved = params.onRecordSaved || (() => {}); - - this._requiredFields = {}; - for (const [fieldName, activeField] of Object.entries(this.activeFields)) { - const { modifiers } = activeField; - if (modifiers && modifiers.required) { - this._requiredFields[fieldName] = modifiers.required; - } - } - - if (!this.resId && this.__viewType === "form") { - this.mode = "edit"; // always edit a new record in form view. - } else { - this.mode = params.mode || state.mode || "readonly"; - } - - this._onWillSwitchMode = params.onRecordWillSwitchMode || (() => {}); - - if (this.__bm_handle__) { - this.__syncData(); - } - } - - // ------------------------------------------------------------------------- - // Getters - // ------------------------------------------------------------------------- - - get canBeAbandoned() { - return this.model.__bm__.canBeAbandoned(this.__bm_handle__); - } - - /** - * 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. - * - * @returns {boolean} true iff the field is active or there is no `active` - * or `x_active` field on the model - */ - get isActive() { - if ("active" in this.activeFields) { - return this.data.active; - } else if ("x_active" in this.activeFields) { - return this.data.x_active; - } - return true; - } - - get isDirty() { - return this.model.__bm__.isDirty(this.__bm_handle__); - } - - get isInEdition() { - return this.mode === "edit"; - } - - get isNew() { - return !this.resId; - } - - get isValid() { - return !this._invalidFields.size; - } - - get resId() { - if (this.__bm_handle__) { - const resId = this.model.__bm__.localData[this.__bm_handle__].res_id; - if (typeof resId === "string") { - return false; - } - return resId; - } else { - // record not loaded yet - return this.__bm_load_params__.res_id; - } - } - - get resIds() { - return this.model.__bm__.localData[this.__bm_handle__].res_ids; - } - - async askChanges() { - const proms = []; - this.model.bus.trigger("NEED_LOCAL_CHANGES", { proms }); - return Promise.all([...proms, this._updatePromise]); - } - - // ------------------------------------------------------------------------- - // Getters - // ------------------------------------------------------------------------- - - async checkValidity(urgent) { - if (!urgent) { - await this.askChanges(); - } - for (const fieldName in this.activeFields) { - const fieldType = this.fields[fieldName].type; - const activeField = this.activeFields[fieldName]; - if (fieldName in this._requiredFields) { - if ( - !evalDomain(this._requiredFields[fieldName], this.evalContext) || - (activeField && activeField.alwaysInvisible) - ) { - this._removeInvalidFields([fieldName]); - continue; - } - } - - const isSet = activeField && activeField.field && activeField.field.isSet; - - if (this._isRequired(fieldName) && isSet && !isSet(this.data[fieldName])) { - this.setInvalidField(fieldName); - continue; - } - - switch (fieldType) { - case "boolean": - case "float": - case "integer": - case "monetary": - continue; - case "properties": - if (!this.checkPropertiesValidity(fieldName)) { - this._setInvalidField(fieldName); - } - break; - case "one2many": - case "many2many": - if (!(await this.checkX2ManyValidity(fieldName, urgent))) { - this._setInvalidField(fieldName); - } - break; - default: - if (!isSet && this._isRequired(fieldName) && !this.data[fieldName]) { - this._setInvalidField(fieldName); - } - } - } - return !this._invalidFields.size; - } - - openInvalidFieldsNotification() { - if (this._invalidFields.size) { - const invalidFields = [...this._invalidFields].map((fieldName) => { - return `
  • ${escape(this.fields[fieldName].string || fieldName)}
  • `; - }, this); - this._closeInvalidFieldsNotification = this.model.notificationService.add( - markup(`
      ${invalidFields.join("")}
    `), - { - title: this.model.env._t("Invalid fields: "), - type: "danger", - } - ); - } - } - - async switchMode(mode, options) { - if (this.mode === mode) { - return true; - } - const canSwitch = await this._onWillSwitchMode(this, mode, options); - if (canSwitch === false) { - return false; - } - - if (mode === "edit") { - // wait for potential pending changes to be saved (done with widgets - // allowing to edit in readonly) - await this.model.__bm__.mutex.getUnlockedDef(); - } - - if (mode === "readonly") { - for (const fieldName in this.activeFields) { - if (["one2many", "many2many"].includes(this.fields[fieldName].type)) { - const editedRecord = this.data[fieldName] && this.data[fieldName].editedRecord; - if (editedRecord) { - editedRecord.switchMode("readonly"); - } - } - } - } - this.mode = mode; - this.model.notify(); - return true; - } - - isFieldDirty(fieldName) { - const changes = this.model.__bm__.localData[this.__bm_handle__]._changes; - return changes && changes[fieldName]; - } - - /** - * FIXME: memoize this at some point? - * @param {string} fieldName - * @returns {boolean} - */ - _isReadonly(fieldName) { - const { readonly } = this.activeFields[fieldName].modifiers || {}; - return evalDomain(readonly, this.evalContext); - } - - /** - * FIXME: memoize this at some point? - * @param {string} fieldName - * @returns {boolean} - */ - _isRequired(fieldName) { - const { required } = this.activeFields[fieldName].modifiers || {}; - return evalDomain(required, this.evalContext); - } - - async checkX2ManyValidity(fieldName, urgent = false) { - const list = this.data[fieldName]; - const record = list.editedRecord; - if (record && !(await record.checkValidity(urgent))) { - if (record.canBeAbandoned && !record.isDirty) { - list.abandonRecord(record.id); - } else { - return false; - } - } - return true; - } - - /** - * The label and the id of the properties are always required. - * - * @param {string} fieldName - * @returns {boolean} - */ - checkPropertiesValidity(fieldName) { - const value = this.data[fieldName]; - if (!value) { - return true; - } - return value.every( - (propertyDefinition) => - !propertyDefinition.id || - (propertyDefinition.string && propertyDefinition.string.length) - ); - } - - _setInvalidField(fieldName) { - this._invalidFields.add(fieldName); - this.model.notify(); - } - - setInvalidField(fieldName) { - const bm = this.model.__bm__; - bm.setDirty(this.__bm_handle__); - this._setInvalidField(fieldName); - } - - isInvalid(fieldName) { - return this._invalidFields.has(fieldName); - } - - async load(params = {}, options = {}) { - if (!this.__bm_handle__) { - this.__bm_handle__ = await this.model.__bm__.load({ - ...this.__bm_load_params__, - viewType: this.__viewType, - }); - } else { - this.__bm_handle__ = await this.model.__bm__.reload(this.__bm_handle__, { - viewType: this.__viewType, - keepChanges: !!options.keepChanges, - }); - } - this.__syncData(); - this._invalidFields = new Set(); - } - - exportState() { - return { - mode: this.mode, - resId: this.resId, - resIds: this.resIds, - }; - } - - __syncData(force) { - const bm = this.model.__bm__; - const legDP = bm.get(this.__bm_handle__); - const data = Object.assign({}, legDP.data); - for (const fieldName of this.fieldNames) { - const fieldType = legDP.fields[fieldName].type; - switch (fieldType) { - case "date": - case "datetime": { - // from moment to luxon - if (data[fieldName]) { - const deserialize = - fieldType === "date" ? deserializeDate : deserializeDateTime; - data[fieldName] = deserialize(data[fieldName].toJSON()); - } - break; - } - case "one2many": - case "many2many": { - const currentVal = this.data[fieldName]; - if ( - !data[fieldName] || - (currentVal && currentVal.__bm_handle__ === data[fieldName].id && !force) - ) { - data[fieldName] = this.data[fieldName]; - data[fieldName].__syncData(); - } else { - const { viewMode, views } = this.activeFields[fieldName]; - const handleField = - (views && views[viewMode] && views[viewMode].handleField) || null; - data[fieldName] = new StaticList(this.model, { - handle: data[fieldName].id, - handleField, - viewType: viewMode, - __syncParent: async (value) => { - await this.model.__bm__.save(this.__bm_handle__, { - savePoint: true, - }); - await this.update({ [fieldName]: value }); - }, - }); - data[fieldName].__fieldName__ = fieldName; - } - break; - } - case "many2one": { - if (data[fieldName]) { - data[fieldName] = [ - data[fieldName].data.id, - data[fieldName].data.display_name || "", - ]; - } else { - data[fieldName] = false; - } - break; - } - case "reference": { - data[fieldName] = data[fieldName] - ? { - resModel: data[fieldName].model, - resId: data[fieldName].data.id, - displayName: data[fieldName].data.display_name, - } - : false; - break; - } - case "text": - case "char": { - data[fieldName] = data[fieldName] || ""; - break; - } - case "html": { - data[fieldName] = markup(data[fieldName] || ""); - break; - } - } - if (legDP.specialData[fieldName]) { - this.preloadedData[fieldName] = legDP.specialData[fieldName]; - } - } - this.data = data; - } - - getFieldDomain(fieldName) { - const { domain } = this.fields[fieldName]; - return domain ? new Domain(domain).toList(this.getEvalContext(true)) : []; - } - - _getFieldContext(fieldName) { - return this.model.__bm__.localData[this.__bm_handle__].getContext({ - fieldName, - viewType: this.__viewType, - }); - } - - async update(changes) { - if (this.batchingUpdateProm) { - // Assign changes in the current batch - Object.assign(this.batchChanges, changes); - return this._updatePromise; - } - - this.batchingUpdateProm = Promise.resolve(); - this.batchChanges = Object.assign({}, changes); - - let resolveUpdatePromise; - this._updatePromise = new Promise((r) => { - resolveUpdatePromise = r; - }); - - await this.batchingUpdateProm; - changes = this.batchChanges; - this.batchingUpdateProm = null; - this.batchChanges = null; - - const data = {}; - for (const [fieldName, value] of Object.entries(changes)) { - const fieldType = this.fields[fieldName].type; - data[fieldName] = mapWowlValueToLegacy(value, fieldType); - // special case for many2ones: they can be updated with a new name (e.g. if edited from - // the dialog), but in the basic_model it worked differently, we had a datapoint for the - // many2one value and we reloaded it directly. In the new model, we directly update the - // value [id, display_name], so we reload beforehand, in the many2one field itself. In - // the next few lines, we thus manually apply the renaming on the legacy datapoint. - if (this.fields[fieldName].type === "many2one" && Array.isArray(changes[fieldName])) { - const newName = changes[fieldName][1]; - if (newName || newName === "") { - const bm = this.model.__bm__; - const m2oDatapointId = bm.get(this.__bm_handle__).data[fieldName].id; - const m2oDatapoint = bm.localData[m2oDatapointId]; - if (m2oDatapoint && m2oDatapoint.data.id === changes[fieldName][0]) { - m2oDatapoint.data.display_name = newName; - } - } - } - // same for reference fields - if (this.fields[fieldName].type === "reference" && changes[fieldName].displayName) { - const bm = this.model.__bm__; - const m2oDatapointId = bm.get(this.__bm_handle__).data[fieldName].id; - const m2oDatapoint = bm.localData[m2oDatapointId]; - if (m2oDatapoint) { - m2oDatapoint.data.display_name = changes[fieldName].displayName; - } - } - } - if (this._urgentSave) { - const fieldNames = await this.model.__bm__.notifyChanges(this.__bm_handle__, data, { - viewType: this.__viewType, - notifyChange: false, - }); - resolveUpdatePromise(); - this._removeInvalidFields(fieldNames); - this.__syncData(); - return; - } - - const parentID = this.model.__bm__.localData[this.__bm_handle__].parentID; - if (parentID && this.__viewType === "list") { - // inside an x2many (parentID is the id of the static list datapoint) - const mainRecordId = this.model.__bm__.localData[parentID].parentID; - const mainRecordDP = this.model.__bm__.localData[mainRecordId]; - const mainRecordValues = { ...mainRecordDP.data, ...mainRecordDP._changes }; - const x2manyFieldName = Object.keys(mainRecordValues).find( - (name) => mainRecordValues[name] === parentID - ); - if (!x2manyFieldName) { - throw new Error("couldn't find x2many field name"); - } - const operation = { operation: "UPDATE", id: this.__bm_handle__, data }; - const prom = this.__syncParent(operation); - prom.catch(resolveUpdatePromise); - await prom; - } else { - const prom = this.model.__bm__.notifyChanges(this.__bm_handle__, data, { - viewType: this.__viewType, - }); - prom.catch(resolveUpdatePromise); // onchange rpc may return an error - const fieldNames = await prom; - this._removeInvalidFields(fieldNames); - for (const fieldName of fieldNames) { - if (["one2many", "many2many"].includes(this.fields[fieldName].type)) { - const { editedRecord } = this.data[fieldName]; - if (editedRecord) { - editedRecord._removeAllInvalidFields(); - } - } - } - this.__syncData(); - } - this._removeInvalidFields(Object.keys(changes)); - this.model.notify(); - resolveUpdatePromise(); - } - - /** - * - * @param {Object} options - * @param {boolean} [options.stayInEdition=false] - * @param {boolean} [options.noReload=false] prevents the record from - * reloading after changes are applied, typically used to defer the load. - * @param {boolean} [options.useSaveErrorDialog=false] displays a custom - * dialog and await the response from this dialog when an error is - * returned by the server. - * @param {boolean} [options.throwOnError=false] throws the saving error if - * applicable, allowing to catch it. - * @returns {Promise} - */ - async save(options = {}) { - options = Object.assign( - { - stayInEdition: true, - noReload: false, - savePoint: false, - useSaveErrorDialog: false, - throwOnError: false, - }, - options - ); - const shouldSwitchToReadonly = !options.stayInEdition && this.isInEdition; - let resolveSavePromise; - this._savePromise = new Promise((r) => { - resolveSavePromise = r; - }); - this._closeInvalidFieldsNotification(); - if (!(await this.checkValidity())) { - this.openInvalidFieldsNotification(); - resolveSavePromise(); - return false; - } - if ((await this.onWillSaveRecord(this)) === false) { - return false; - } - const saveOptions = { - reload: !options.noReload, - savePoint: options.savePoint, - }; - try { - await this.model.__bm__.save(this.__bm_handle__, saveOptions); - } catch (_e) { - resolveSavePromise(); - let canProceed = false; - if (options.useSaveErrorDialog) { - _e.__raisedOnFormSave = true; - canProceed = await new Promise((resolve) => { - _e.onDiscard = async () => { - await this.discard(); - resolve(true); - }; - _e.onStayHere = () => resolve(false); - }); - } - - if (!this.isInEdition) { - await this.load(); - this.model.notify(); - } - if (options.throwOnError) { - throw _e; - } - return canProceed; - } - this.__syncData(true); - if (shouldSwitchToReadonly) { - this.switchMode("readonly"); - } - this.model.notify(); - resolveSavePromise(); - - await this.onRecordSaved(this); - return true; - } - - /** - * To be called **only** when Odoo is about to be closed, and we want to - * save potential changes on a given record. - * - * We can't follow the normal flow (onchange(s) + save, mutexified), - * because the 'beforeunload' handler must be *almost* sync (< 10 ms - * setTimeout seems fine, but an rpc roundtrip is definitely too long), - * so here we bypass the standard mechanism of notifying changes and - * saving them: - * - we ask the model to bypass its mutex for upcoming 'notifyChanges' and - * 'save' requests - * - we ask all fields to commit their changes (in case there would - * be a focused field with a fresh value) - * - we take all changes that have been reported to the - * controller, but not yet sent to the model because of the mutex, - * and directly notify the model about them, see update. - * - we reset the widgets with all those changes, s.t. a further call - * to 'canBeRemoved' uses the correct data (it asks the widgets if - * they are set/valid, based on their internal state) - * - if the record is dirty, we save directly - * - * @param {string} recordID - * @private - */ - async urgentSave() { - this.model.__bm__.bypassMutex = true; - this._urgentSave = true; - this.model.bus.trigger("WILL_SAVE_URGENTLY"); - await Promise.resolve(); - this.__syncData(); - let isValid = true; - if (this.isDirty) { - isValid = await this.checkValidity(true); - if (isValid) { - this.model.__bm__.save(this.__bm_handle__, { reload: false }); - } - } - this.model.__bm__.bypassMutex = false; - return isValid; - } - - async archive() { - await this.model.__bm__.actionArchive([this.resId], this.__bm_handle__); - this.__syncData(); - this.model.notify(); - } - - async unarchive() { - await this.model.__bm__.actionUnarchive([this.resId], this.__bm_handle__); - this.__syncData(); - this.model.notify(); - } - - // FIXME AAB: to discuss: not sure we want to keep resIds in the model (this concerns - // "duplicate" and "delete"). Alternative: handle this in form_view (but then what's the - // point of calling a Record method to do the operation?) - async duplicate() { - this.__bm_handle__ = await this.model.__bm__.duplicateRecord(this.__bm_handle__); - this.__syncData(); - this.switchMode("edit"); - this.model.notify(); - } - - async delete() { - await this.model.__bm__.deleteRecords([this.__bm_handle__], this.resModel); - if (this.resIds.length) { - await this.load(); - } - this.model.notify(); - } - - toggleSelection(selected) { - if (typeof selected === "boolean") { - this.selected = selected; - } else { - this.selected = !this.selected; - } - this.model.notify(); - } - - async discard() { - await this._savePromise; - this._closeInvalidFieldsNotification(); - this.model.__bm__.discardChanges(this.__bm_handle__); - this._invalidFields = new Set(); - this.__syncData(); - this.model.notify(); - } - - _removeInvalidFields(fieldNames) { - for (const fieldName of fieldNames) { - this._invalidFields.delete(fieldName); - } - } - - _removeAllInvalidFields() { - this._removeInvalidFields(Object.keys(this.activeFields)); - } -} - -export class StaticList extends DataPoint { - setup(params) { - /** @type {Record[]} */ - this.records = []; - - this.handleField = params.handleField; - - this.editedRecord = null; - this.onRecordWillSwitchMode = async (record, mode, options = {}) => { - if (mode === "edit") { - await this.model.__bm__.save(this.__bm_handle__, { savePoint: true }); - this.model.__bm__.freezeOrder(this.__bm_handle__); - } - - const editedRecord = this.editedRecord; - this.editedRecord = null; - if (editedRecord) { - // Validity is checked if one of the following is true: - // - "switchMode" has been called with explicit "checkValidity" - // - the record is dirty - // - the record is new and can be abandonned - const shouldCheckValidity = - options.checkValidity || editedRecord.isDirty || editedRecord.canBeAbandoned; - const isValid = !shouldCheckValidity || (await editedRecord.checkValidity()); - if (isValid) { - await editedRecord.switchMode("readonly"); - } else if (editedRecord.id !== record.id && editedRecord.canBeAbandoned) { - this.abandonRecord(editedRecord.id); - } else { - this.editedRecord = editedRecord; - return false; - } - } - if (mode === "edit") { - this.editedRecord = record; - } - }; - - this.__syncData(); - } - - __syncData() { - const legacyListDP = this.model.__bm__.get(this.__bm_handle__); - this.records = legacyListDP.data.map((dp) => { - let record = this.records.find((r) => r.__bm_handle__ === dp.id); - if (record) { - record.__syncData(); - } else { - record = new this.model.constructor.Record(this.model, { - handle: dp.id, - onRecordWillSwitchMode: this.onRecordWillSwitchMode, - mode: "readonly", - viewType: this.__viewType, - __syncParent: async (value) => { - await this.__syncParent(value); - }, - }); - if (record.mode === "edit" && this.editedRecord) { - this.editedRecord.mode = "readonly"; - } - } - return record; - }); - this.editedRecord = this.records.find((r) => r.mode === "edit") || null; - } - - // FIXME: remove? - get resIds() { - return this.model.__bm__.get(this.__bm_handle__).res_ids; - } - get currentIds() { - return this.model.__bm__.get(this.__bm_handle__).res_ids; - } - - get orderBy() { - return this.model.__bm__.localData[this.__bm_handle__].orderedBy; - } - get limit() { - return this.model.__bm__.localData[this.__bm_handle__].limit; - } - get offset() { - return this.model.__bm__.localData[this.__bm_handle__].offset; - } - get count() { - return this.currentIds.length; - } - - abandonRecord(recordId) { - const record = this.records.find((r) => r.id === recordId); - this.model.__bm__.removeLine(record.__bm_handle__); - this.__syncData(); - this.model.notify(); - } - - canResequence() { - return this.handleField || DEFAULT_HANDLE_FIELD in this.fields; - } - - removeRecord(record) { - // if (true) { see _onRemoveRecord in rel fields - this.delete(record.id); - // } - } - - async delete(recordId, operation = "DELETE") { - const record = this.records.find((r) => r.id === recordId); - await this.__syncParent({ operation, ids: [record.__bm_handle__] }); - } - - async add(object, params = { isM2M: false }) { - let operation; - const bm = this.model.__bm__; - if (object instanceof Record) { - const recHandle = object.__bm_handle__; - await bm.save(recHandle, { savePoint: !params.isM2M, viewType: object.__viewType }); - if (params.isM2M) { - const id = bm.localData[recHandle].res_id; - operation = { operation: "ADD_M2M", ids: [{ id }] }; - } else { - operation = { operation: "ADD", id: recHandle }; - } - } else if (Array.isArray(object) && params.isM2M) { - const oldIds = this.resIds; - const newIds = object.filter((id) => !oldIds.includes(id)).map((id) => ({ id })); - operation = { operation: "ADD_M2M", ids: newIds }; - } - await this.__syncParent(operation); - } - - /** Creates a Draft record from nothing and edits it. Relevant in editable x2m's */ - async addNew(params) { - const position = params.position; - const operation = { context: [params.context], operation: "CREATE", position }; - await this.model.__bm__.save(this.__bm_handle__, { savePoint: true }); - this.model.__bm__.freezeOrder(this.__bm_handle__); - await this.__syncParent(operation); - const newRecord = this.records[position === "bottom" ? this.records.length - 1 : 0]; - if (params.mode === "edit") { - await newRecord.switchMode("edit"); - } - return newRecord; - } - - // x2many dialog edition - // addRecord(record) { - // this.onChanges(); - // this._cache[record.virtualId] = record; - // this._cache[record.virtualId] = record; - // this.records.push(record); - // this.resIds.push(record.virtualId); - // this.limit = this.limit + 1; // might be not good - // this.validated[record.virtualId] = false; - // this.model.notify(); - // } - - exportState() { - return { - limit: this.limit, - }; - } - - async load(params) { - await this.model.__bm__._reload(this.__bm_handle__, params); - this.__syncData(); - } - - async sortBy(fieldName) { - await this.model.__bm__.setSort(this.__bm_handle__, fieldName); - this.__syncData(); - this.model.notify(); - } - - async replaceWith(resIds) { - const addedIds = resIds.filter((id) => !this.currentIds.includes(id)); - const deletedIds = this.currentIds.filter((id) => !resIds.includes(id)); - let operation; - if (addedIds.length && deletedIds.length) { - operation = { - ids: resIds, - operation: "REPLACE_WITH", - }; - } else if (addedIds.length) { - operation = { - ids: addedIds.map((id) => ({ id })), - operation: "ADD_M2M", - }; - } else if (deletedIds.length) { - operation = { - ids: deletedIds, - operation: "FORGET", - }; - } else { - // no change? - return; - } - await this.__syncParent(operation); - } - - /** - * @param {Array[]} commands array of commands - */ - async applyCommands(fieldName, commands) { - const commandsWithId = commands.map((command) => { - return { - operation: command.operation, - id: command.record.__bm_handle__, - data: command.data, - }; - }); - - const parentID = this.model.__bm__.localData[this.__bm_handle__].parentID; - await this.model.__bm__.notifyChanges(parentID, { - [fieldName]: { - operation: "MULTI", - commands: commandsWithId, - }, - }); - - this.model.root.__syncData(); - this.model.notify(); - } - - /** - * @param {string} dataRecordId - * @param {string} dataGroupId - * @param {string} refId - * @param {string} targetGroupId - * @returns {Promise} - */ - async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) { - await this.resequence(dataRecordId, refId); - } - - /** - * @param {RecordId} movedId // id of the moved record - * @param {RecordId | null} targetId // id of the record (if any) that must be before moved record after operation is done - */ - async resequence(movedId, targetId) { - if (!this.canResequence()) { - // There is no handle field on the current model - return; - } - - if (this.__viewType === "list") { - await this.model.__bm__.save(this.__bm_handle__, { savePoint: true }); - this.model.__bm__.freezeOrder(this.__bm_handle__); - } - - const handleField = this.handleField || DEFAULT_HANDLE_FIELD; - const records = [...this.records]; - const order = this.orderBy.find((o) => o.name === handleField); - const asc = !order || order.asc; - - // Find indices - const fromIndex = records.findIndex((r) => r.id === movedId); - let toIndex = 0; - if (targetId !== null) { - const targetIndex = records.findIndex((r) => r.id === targetId); - toIndex = fromIndex > targetIndex ? targetIndex + 1 : targetIndex; - } - - const getSequence = (rec) => rec && rec.data[handleField]; - - // Determine what records need to be modified - const firstIndex = Math.min(fromIndex, toIndex); - const lastIndex = Math.max(fromIndex, toIndex) + 1; - let reorderAll = false; - let lastSequence = (asc ? -1 : 1) * Infinity; - for (let index = 0; index < records.length; index++) { - const sequence = getSequence(records[index]); - if ( - ((index < firstIndex || index >= lastIndex) && - ((asc && lastSequence >= sequence) || (!asc && lastSequence <= sequence))) || - (index >= firstIndex && index < lastIndex && lastSequence === sequence) - ) { - reorderAll = true; - } - lastSequence = sequence; - } - - // Perform the resequence in the list of records - const [record] = records.splice(fromIndex, 1); - records.splice(toIndex, 0, record); - - // Creates the list of to modify - let toReorder = records; - if (!reorderAll) { - toReorder = toReorder.slice(firstIndex, lastIndex).filter((r) => r.id !== movedId); - if (fromIndex < toIndex) { - toReorder.push(record); - } else { - toReorder.unshift(record); - } - } - if (!asc) { - toReorder.reverse(); - } - - const sequences = toReorder.map(getSequence); - const offset = sequences.length && Math.min(...sequences); - - const operations = toReorder.map((record, i) => ({ - operation: "UPDATE", - id: record.__bm_handle__, - data: { [handleField]: offset + i }, - })); - const lastOperation = operations.pop(); - - const parentID = this.model.__bm__.localData[this.__bm_handle__].parentID; - - await Promise.all( - operations.map((op) => { - this.model.__bm__.notifyChanges( - parentID, - { [this.__fieldName__]: op }, - { notifyChange: false, viewType: "form" } - ); - }) - ); - - try { - await this.model.__bm__.notifyChanges( - parentID, - { - [this.__fieldName__]: lastOperation, - }, - { viewType: "form" } - ); - } finally { - if (this.__viewType === "list") { - await this.model.__bm__.setSort(this.__bm_handle__, handleField); - } - } - - this.records = records; - - this.__syncData(); - this.model.notify(); - } - - async unselectRecord(canDiscard = false) { - // something seems wrong with switchMode --> review system? - const editedRecord = this.editedRecord; - if (!editedRecord) { - return true; - } - - const isValid = await editedRecord.checkValidity(); - const handle = editedRecord.__bm_handle__; - if (!editedRecord.isDirty && ((canDiscard && editedRecord.isNew) || !isValid)) { - this.model.__bm__.discardChanges(handle); - if (editedRecord.canBeAbandoned) { - this.model.__bm__.removeLine(handle); - } - this.__syncData(); - this.editedRecord = null; - return await editedRecord.switchMode("readonly"); - } else if (isValid) { - return await editedRecord.switchMode("readonly"); - } - return false; - } -} - -export class RelationalModel extends Model { - setup(params, { action, dialog, notification }) { - this.actionService = action; - this.dialogService = dialog; - this.notificationService = notification; - this.keepLast = new KeepLast(); - - if (params.rootType !== "record") { - throw "only record root type is supported"; - } - - this.__component = params.component; - - this.root = null; - - this.__bm__ = new this.constructor.LegacyModel(this, { - fields: params.fields || {}, - modelName: params.resModel, - useSampleModel: false, // FIXME AAB - }); - this.__activeFields = params.activeFields; - this.__fields = params.fields; - - const res_id = params.resId || undefined; - const res_ids = (params.resIds ? toRaw(params.resIds) : null) || (res_id ? [res_id] : []); - - this.__bm_load_params__ = { - type: "record", - modelName: params.resModel, - res_id, - res_ids, - fields: params.fields || {}, - viewType: "form", - }; - - this.initialMode = params.mode; - - this.onWillSaveRecord = params.onWillSaveRecord || (() => {}); - this.onRecordSaved = params.onRecordSaved || (() => {}); - } - - async duplicateDatapoint(record, params) { - const bm = this.__bm__; - const fieldsInfo = mapViews(params.views, this.env); - const handle = record.__bm_handle__; - - // Sync with the mutex to wait for potential onchanges - await bm.mutex.getUnlockedDef(); - - await bm.addFieldsInfo(handle, { - fields: params.fields, - viewType: params.viewMode, - fieldInfo: fieldsInfo[params.viewMode].fieldsInfo[params.viewMode], - }); - - // determine fieldNames to load (comes from basic_view.js) - const legRec = this.__bm__.get(record.__bm_handle__); - const viewType = params.viewMode; - const viewFields = Object.keys(legRec.fieldsInfo[viewType]); - const fieldNames = viewFields.filter((x) => Object.keys(legRec.data).indexOf(x) < 0); - const legFieldsInfo = legRec.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 legRec.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. - for (const name of viewFields) { - if (!fieldNames.includes(name)) { - const fieldType = legRec.fields[name].type; - const fieldInfo = legFieldsInfo[name]; - // SpecialData case: field requires specialData that haven't - // been fetched yet. - if (fieldInfo.specialData && !(name in legRec.specialData)) { - fieldNames.push(name); - continue; - } - - // 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 legRec.data[name])) { - fieldNames.push(name); - } else { - const x2mFieldInfo = legRec.fieldsInfo[params.viewMode][name]; - const viewType = x2mFieldInfo.viewType || x2mFieldInfo.mode; - const knownFields = Object.keys( - legRec.data[name].fieldsInfo[legRec.data[name].viewType] || {} - ); - const newFields = Object.keys(legRec.data[name].fieldsInfo[viewType] || {}); - if (newFields.filter((f) => !knownFields.includes(f)).length) { - fieldNames.push(name); - } - - if (legRec.data[name].viewType === "default") { - // Use case: x2many (tags) in x2many list views - // When opening the x2many legRec 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) - const fieldViews = fieldInfo.views || fieldInfo.fieldsInfo || {}; - const defaultFieldInfo = legRec.data[name].fieldsInfo.default; - Object.values(fieldViews).forEach((fieldView) => { - Object.assign(defaultFieldInfo, fieldView.fields); - Object.values(fieldView.fieldsInfo).forEach((x2mFieldInfo) => { - Object.assign(defaultFieldInfo, x2mFieldInfo); - }); - }); - } - } - } - // 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(legRec.data[name].context) !== - JSON.stringify(fieldInfo.context) - ) { - fieldNames.push(name); - } - } - } - } - - if (fieldNames.length) { - if (this.__bm__.isNew(record.__bm_handle__)) { - await this.__bm__.generateDefaultValues(record.__bm_handle__, { - fieldNames: fieldNames, - viewType: viewType, - }); - } else { - await this.__bm__.reload(record.__bm_handle__, { - fieldNames: fieldNames, - keepChanges: true, - viewType: viewType, - }); - } - } - - const Record = this.constructor.Record; - const newRecord = new Record(this, { - handle, - viewType: params.viewMode, - mode: params.mode, - }); - - const recordSave = Record.prototype.save; - record.save = recordSave; - record.save = async (...args) => { - record.__syncData(); - const res = await recordSave.call(record, ...args); - record.save = recordSave; - return res; - }; - - return newRecord; - } - async addNewRecord(list, params, withParentId = true) { - const parentId = this.__bm__.localData[list.__bm_handle__].parentID; - const fieldName = list.__fieldName__; - const context = this.__bm__._getContext(this.__bm__.localData[parentId], { fieldName }); - params.context = makeContext([context, params.context]); - params.__syncParent = () => list.__syncData(); - const newRecord = this.createDataPoint("record", params); - if (withParentId) { - newRecord.__bm_load_params__.parentID = list.__bm_handle__; - } - await newRecord.load(); - return newRecord; - } - async updateRecord(list, record, params = { isM2M: false }) { - let operation; - const isM2M = params.isM2M; - if (!isM2M) { - operation = { operation: "UPDATE", id: record.__bm_handle__ }; - } else { - await record.save({ noReload: true }); - operation = { operation: "TRIGGER_ONCHANGE" }; - } - await list.__syncParent(operation); - if (isM2M) { - await record.load(); - } - } - - /** - * @param {Object} [params={}] - * @param {Comparison | null} [params.comparison] - * @param {Context} [params.context] - * @param {DomainListRepr} [params.domain] - * @param {string[]} [params.groupBy] - * @param {Object[]} [params.orderBy] - * @param {number} [params.resId] should not be there - * @returns {Promise} - */ - async load(params = {}) { - if (!this.__bm_load_params__.fieldsInfo) { - // only the first time (must wait for the subviews to be loaded) - this.__bm_load_params__.fieldsInfo = mapActiveFieldsToFieldsInfo( - this.__activeFields || {}, - this.__fields || {}, - "form", - this.env - ); - } - const loadParams = { ...this.__bm_load_params__ }; - if ("resId" in params) { - loadParams.res_id = params.resId || undefined; - } - if ("context" in params) { - loadParams.context = params.context; - } - const state = this.root ? this.root.exportState() : {}; - const nextRoot = new this.constructor.Record( - this, - { - __bm_load_params__: loadParams, - mode: this.initialMode, - onWillSaveRecord: this.onWillSaveRecord, - onRecordSaved: this.onRecordSaved, - }, - state - ); - await this.keepLast.add(nextRoot.load()); - this.root = nextRoot; - this.__bm_load_params__ = loadParams; - this.notify(); - } - _trigger_up(ev) { - const evType = ev.name; - const payload = ev.data; - if (evType === "call_service") { - let args = payload.args || []; - if (payload.service === "ajax" && payload.method === "rpc") { - // ajax service uses an extra 'target' argument for rpc - args = args.concat(ev.target); - if (owl.status(this.__component) === "destroyed") { - console.warn("Component is destroyed"); - return payload.callback(new Promise(() => {})); - } - const prom = new Promise((resolve, reject) => { - owl.Component.env.session - .rpc(...args) - .then((value) => { - if (owl.status(this.__component) !== "destroyed") { - resolve(value); - } - }) - .guardedCatch((reason) => { - if (owl.status(this.__component) !== "destroyed") { - reject(reason); - } - }); - }); - return payload.callback(prom); - } else if (payload.service === "notification") { - return this.notificationService.add(payload.message, { - className: payload.className, - sticky: payload.sticky, - title: payload.title, - type: payload.type, - }); - } - throw new Error(`call service ${payload.service} not handled in relational model`); - } else if (evType === "warning") { - if (payload.type === "dialog") { - return this.dialogService.add(WarningDialog, { - title: payload.title, - message: payload.message, - }); - } else { - return this.notificationService.add(payload.message, { - className: payload.className, - sticky: payload.sticky, - title: payload.title, - type: "warning", - }); - } - } else if (evType === "do_action") { - // SAME CODE AS legacy_service_provider - if (payload.action.context) { - payload.action.context = new Context(payload.action.context).eval(); - } - const legacyOptions = mapDoActionOptionAPI(payload.options); - return this.actionService.doAction(payload.action, legacyOptions); - } else if (evType === "reload") { - return this.load().then(() => { - if (ev.data.onSuccess) { - ev.data.onSuccess(); - } - }); - } - throw new Error(`trigger_up(${evType}) not handled in relational model`); - } - createDataPoint(type, params, state = {}) { - if (type !== "record") { - throw new Error("LPE CRASH"); - } - const fieldsInfo = mapActiveFieldsToFieldsInfo( - params.activeFields, - params.fields, - params.viewType, - this.env - ); - params = Object.assign({}, params, { - __bm_load_params__: { - type: "record", - modelName: params.resModel, - fields: params.fields || {}, - viewType: "form", - fieldsInfo, - parentID: params.parentID, - context: params.context, - }, - }); - return new this.constructor.Record(this, params, state); - } -} -RelationalModel.services = ["action", "dialog", "notification"]; -RelationalModel.LegacyModel = BasicModel; -RelationalModel.Record = Record; diff --git a/addons/web/static/src/views/calendar/calendar_arch_parser.js b/addons/web/static/src/views/calendar/calendar_arch_parser.js index 161751994aa..01d87ce3fa7 100644 --- a/addons/web/static/src/views/calendar/calendar_arch_parser.js +++ b/addons/web/static/src/views/calendar/calendar_arch_parser.js @@ -35,7 +35,7 @@ export class CalendarArchParser extends XMLParser { let isDateHidden = false; let isTimeHidden = false; let formViewId = false; - const popoverFields = {}; + const popoverFieldNodes = {}; const filtersInfo = {}; this.visitXML(arch, (node) => { @@ -122,7 +122,7 @@ export class CalendarArchParser extends XMLParser { "calendar", jsClass ); - popoverFields[fieldName] = fieldInfo; + popoverFieldNodes[fieldName] = fieldInfo; const field = fields[fieldName]; if (!node.hasAttribute("invisible") || node.hasAttribute("filters")) { @@ -193,7 +193,7 @@ export class CalendarArchParser extends XMLParser { quickCreate, isDateHidden, isTimeHidden, - popoverFields, + popoverFieldNodes, scale, scales, showUnusualDays, diff --git a/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.js b/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.js index e50adc111e0..1bb8e840374 100644 --- a/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.js +++ b/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.js @@ -1,11 +1,11 @@ /** @odoo-module **/ import { Dialog } from "@web/core/dialog/dialog"; +import { evalDomain } from "@web/core/domain"; import { is24HourFormat } from "@web/core/l10n/dates"; import { Field } from "@web/views/fields/field"; import { Record } from "@web/views/record"; -import { evalDomain } from "@web/views/utils"; -import { getFormattedDateSpan } from '@web/views/calendar/utils'; +import { getFormattedDateSpan } from "@web/views/calendar/utils"; import { Component } from "@odoo/owl"; @@ -19,6 +19,9 @@ export class CalendarCommonPopover extends Component { this.computeDateTimeAndDuration(); } + get activeFields() { + return this.props.model.popoverFields; + } get isEventEditable() { return true; } @@ -29,9 +32,8 @@ export class CalendarCommonPopover extends Component { return this.isEventEditable || this.isEventDeletable; } - isInvisible(fieldName, record) { - const { invisible } = this.props.model.popoverFields[fieldName].modifiers; - return evalDomain(invisible, record.evalContext); + isInvisible(fieldNode, record) { + return evalDomain(fieldNode.modifiers.invisible, record.evalContext); } computeDateTimeAndDuration() { diff --git a/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.xml b/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.xml index 48a558a4cfd..ddfdf0d888c 100644 --- a/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.xml +++ b/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.xml @@ -44,10 +44,10 @@
      - - - - + + + +
    • @@ -60,7 +60,7 @@
      - +
    • diff --git a/addons/web/static/src/views/calendar/calendar_controller.js b/addons/web/static/src/views/calendar/calendar_controller.js index d71a9fcebdb..563f3b82c8b 100644 --- a/addons/web/static/src/views/calendar/calendar_controller.js +++ b/addons/web/static/src/views/calendar/calendar_controller.js @@ -5,7 +5,7 @@ import { _t, _lt } from "@web/core/l10n/translation"; import { useOwnedDialogs, useService } from "@web/core/utils/hooks"; import { sprintf } from "@web/core/utils/strings"; import { Layout } from "@web/search/layout"; -import { useModel } from "@web/views/model"; +import { useModel } from "@web/model/model"; import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog"; import { useSetupView } from "@web/views/view_hook"; import { CalendarDatePicker } from "./date_picker/calendar_date_picker"; diff --git a/addons/web/static/src/views/calendar/calendar_model.js b/addons/web/static/src/views/calendar/calendar_model.js index 4db550bf8ac..016e6f68fa9 100644 --- a/addons/web/static/src/views/calendar/calendar_model.js +++ b/addons/web/static/src/views/calendar/calendar_model.js @@ -10,7 +10,8 @@ import { localization } from "@web/core/l10n/localization"; import { _t } from "@web/core/l10n/translation"; import { registry } from "@web/core/registry"; import { KeepLast } from "@web/core/utils/concurrency"; -import { Model } from "@web/views/model"; +import { Model } from "@web/model/model"; +import { extractFieldsFromArchInfo } from "@web/model/relational_model/utils"; export class CalendarModel extends Model { setup(params, services) { @@ -22,8 +23,12 @@ export class CalendarModel extends Model { const formViewFromConfig = (this.env.config.views || []).find((view) => view[1] === "form"); const formViewIdFromConfig = formViewFromConfig ? formViewFromConfig[0] : false; + const fieldNodes = params.popoverFieldNodes; + const { activeFields, fields } = extractFieldsFromArchInfo({ fieldNodes }, params.fields); this.meta = { ...params, + popoverFields: activeFields, + fields, firstDayOfWeek: localization.weekStart, formViewId: params.formViewId || formViewIdFromConfig, }; @@ -110,6 +115,9 @@ export class CalendarModel extends Model { get isTimeHidden() { return this.meta.isTimeHidden; } + get popoverFieldNodes() { + return this.meta.popoverFieldNodes; + } get popoverFields() { return this.meta.popoverFields; } diff --git a/addons/web/static/src/views/debug_items.js b/addons/web/static/src/views/debug_items.js index 39a40dc0ccd..0557b9c9593 100644 --- a/addons/web/static/src/views/debug_items.js +++ b/addons/web/static/src/views/debug_items.js @@ -2,12 +2,12 @@ import { _lt } from "@web/core/l10n/translation"; import { Dialog } from "@web/core/dialog/dialog"; +import { evalDomain } from "@web/core/domain"; import { editModelDebug } from "@web/core/debug/debug_utils"; import { formatDateTime, deserializeDateTime } from "@web/core/l10n/dates"; import { registry } from "@web/core/registry"; import { useService } from "@web/core/utils/hooks"; import { formatMany2one } from "@web/views/fields/formatters"; -import { evalDomain } from "@web/views/utils"; import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog"; import { Component, onWillStart, useState, xml } from "@odoo/owl"; @@ -199,16 +199,12 @@ class SetDefaultDialog extends Component { condition: "", scope: "self", }; - const root = this.props.component.model.root; - this.fields = root.fields; - this.fieldsInfo = root.activeFields; - this.fieldNamesInView = root.fieldNames; + this.fields = this.props.record.fields; + this.activeFields = this.props.record.activeFields; + this.fieldNamesInView = this.props.record.fieldNames; this.fieldNamesBlackList = ["message_attachment_count"]; - this.fieldsValues = root.data; + this.fieldsValues = this.props.record.data; this.modifierDatas = {}; - this.fieldNamesInView.forEach((fieldName) => { - this.modifierDatas[fieldName] = this.fieldsInfo[fieldName].modifiers; - }); this.defaultFields = this.getDefaultFields(); this.conditions = this.getConditions(); } @@ -217,26 +213,22 @@ class SetDefaultDialog extends Component { return this.fieldNamesInView .filter((fieldName) => !this.fieldNamesBlackList.includes(fieldName)) .map((fieldName) => { - const modifierData = this.modifierDatas[fieldName]; - let invisibleOrReadOnly; - if (modifierData) { - const evalContext = this.props.component.model.root.evalContext; - invisibleOrReadOnly = - evalDomain(modifierData.invisible, evalContext) || - evalDomain(modifierData.readonly, evalContext); - } const fieldInfo = this.fields[fieldName]; const valueDisplayed = this.display(fieldInfo, this.fieldsValues[fieldName]); const value = valueDisplayed[0]; const displayed = valueDisplayed[1]; + const evalContext = this.props.record.evalContext; // ignore fields which are empty, invisible, readonly, o2m or m2m if ( !value || - invisibleOrReadOnly || + evalDomain(this.activeFields[fieldName].invisible, evalContext) || + evalDomain(this.activeFields[fieldName].readonly, evalContext) || fieldInfo.type === "one2many" || fieldInfo.type === "many2many" || fieldInfo.type === "binary" || - this.fieldsInfo[fieldName].options.isPassword + Object.entries(this.props.fieldNodes) + .filter(([key, value]) => value.name === fieldName) + .some(([key, value]) => value.options.isPassword) ) { return false; } @@ -292,7 +284,7 @@ class SetDefaultDialog extends Component { return field.name === this.state.fieldToSet; }).value; await this.orm.call("ir.default", "set", [ - this.props.resModel, + this.props.record.resModel, this.state.fieldToSet, fieldToSet, this.state.scope === "self", @@ -305,8 +297,8 @@ class SetDefaultDialog extends Component { SetDefaultDialog.template = "web.DebugMenu.SetDefaultDialog"; SetDefaultDialog.components = { Dialog }; SetDefaultDialog.props = { - resModel: { type: String }, - component: { type: Component }, + record: { type: Object }, + fieldNodes: { type: Object }, close: { type: Function }, }; @@ -316,8 +308,8 @@ export function setDefaults({ component, env }) { description: env._t("Set Defaults"), callback: () => { env.services.dialog.add(SetDefaultDialog, { - resModel: component.props.resModel, - component, + record: component.model.root, + fieldNodes: component.props.archInfo.fieldNodes, }); }, sequence: 310, diff --git a/addons/web/static/src/views/fields/ace/ace_field.js b/addons/web/static/src/views/fields/ace/ace_field.js index c4f4a3e9bc3..02f90112ef5 100644 --- a/addons/web/static/src/views/fields/ace/ace_field.js +++ b/addons/web/static/src/views/fields/ace/ace_field.js @@ -3,11 +3,12 @@ import { _lt } from "@web/core/l10n/translation"; import { registry } from "@web/core/registry"; import { useBus, useService } from "@web/core/utils/hooks"; +import { effect } from "@web/core/utils/reactive"; import { formatText } from "../formatters"; import { standardFieldProps } from "../standard_field_props"; import { CodeEditor } from "@web/core/code_editor/code_editor"; -import { Component, onWillUpdateProps, useState } from "@odoo/owl"; +import { Component, useState } from "@odoo/owl"; import { useDebounced } from "@web/core/utils/timing"; export class AceField extends Component { @@ -23,7 +24,7 @@ export class AceField extends Component { setup() { this.cookies = useService("cookie"); - this.debouncedCommit = useDebounced(this.commitChanges, 10); + this.debouncedCommit = useDebounced(this.commitChanges, 5000); this.state = useState({ onChange: (value) => this.handleChange(value), @@ -31,8 +32,17 @@ export class AceField extends Component { theme: this.theme, }); - this.updateCodeEditor(this.props); - onWillUpdateProps(this.updateCodeEditor); + this.lastSetValue = false; + effect( + async ({ record, mode, name, readonly }) => { + if (this.lastSetValue !== record.data[name]) { + this.state.value = formatText(record.data[name]); + } + this.state.mode = mode === "xml" ? "qweb" : mode; + this.state.readonly = readonly; + }, + [this.props] + ); const { model } = this.props.record; useBus(model.bus, "WILL_SAVE_URGENTLY", () => this.commitChanges()); @@ -46,21 +56,17 @@ export class AceField extends Component { } handleChange(newValue) { + this.props.record.model.bus.trigger("FIELD_IS_DIRTY", true); this.state.value = newValue; this.debouncedCommit(); } - updateCodeEditor({ record, mode, readonly }) { - this.state.value = formatText(record.data[this.props.name]); - this.state.mode = mode === "xml" ? "qweb" : mode; - this.state.readonly = readonly; - } - - commitChanges() { + async commitChanges() { if (!this.props.readonly) { const value = this.state.value; if (this.props.record.data[this.props.name] !== value) { - return this.props.record.update({ [this.props.name]: value }); + this.lastSetValue = value; + await this.props.record.update({ [this.props.name]: value }); } } } diff --git a/addons/web/static/src/views/fields/badge_selection/badge_selection_field.js b/addons/web/static/src/views/fields/badge_selection/badge_selection_field.js index fab3376e2c9..5a13d772810 100644 --- a/addons/web/static/src/views/fields/badge_selection/badge_selection_field.js +++ b/addons/web/static/src/views/fields/badge_selection/badge_selection_field.js @@ -1,24 +1,32 @@ /** @odoo-module **/ -import { registry } from "@web/core/registry"; -import { _lt } from "@web/core/l10n/translation"; -import { standardFieldProps } from "../standard_field_props"; - import { Component } from "@odoo/owl"; +import { _lt } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { useSpecialData } from "@web/views/fields/relational_utils"; +import { standardFieldProps } from "../standard_field_props"; export class BadgeSelectionField extends Component { static template = "web.BadgeSelectionField"; static props = { ...standardFieldProps, + domain: { type: Array, optional: true }, }; + setup() { + this.type = this.props.record.fields[this.props.name].type; + if (this.type === "many2one") { + this.specialData = useSpecialData((orm, props) => { + const { relation } = props.record.fields[props.name]; + return orm.call(relation, "name_search", ["", props.domain]); + }); + } + } + get options() { - switch (this.props.record.fields[this.props.name].type) { + switch (this.type) { case "many2one": - // WOWL: conversion needed while we keep using the legacy model - return Object.values(this.props.record.preloadedData[this.props.name]).map((v) => { - return [v.id, v.display_name]; - }); + return this.specialData.data; case "selection": return this.props.record.fields[this.props.name].selection; default: @@ -27,7 +35,7 @@ export class BadgeSelectionField extends Component { } get string() { - switch (this.props.record.fields[this.props.name].type) { + switch (this.type) { case "many2one": return this.props.record.data[this.props.name] ? this.props.record.data[this.props.name][1] @@ -42,9 +50,7 @@ export class BadgeSelectionField extends Component { } get value() { const rawValue = this.props.record.data[this.props.name]; - return this.props.record.fields[this.props.name].type === "many2one" && rawValue - ? rawValue[0] - : rawValue; + return this.type === "many2one" && rawValue ? rawValue[0] : rawValue; } stringify(value) { @@ -55,7 +61,7 @@ export class BadgeSelectionField extends Component { * @param {string | number | false} value */ onChange(value) { - switch (this.props.record.fields[this.props.name].type) { + switch (this.type) { case "many2one": if (value === false) { this.props.record.update({ [this.props.name]: false }); @@ -77,17 +83,9 @@ export const badgeSelectionField = { displayName: _lt("Badges"), supportedTypes: ["many2one", "selection"], isEmpty: (record, fieldName) => record.data[fieldName] === false, - legacySpecialData: "_fetchSpecialMany2ones", + extractProps: (fieldInfo, dynamicInfo) => ({ + domain: dynamicInfo.domain(), + }), }; registry.category("fields").add("selection_badge", badgeSelectionField); - -export function preloadSelection(orm, record, fieldName, { domain }) { - const field = record.fields[fieldName]; - return orm.call(field.relation, "name_search", ["", domain]); -} - -registry.category("preloadedData").add("selection_badge", { - loadOnTypes: ["many2one"], - preload: preloadSelection, -}); diff --git a/addons/web/static/src/views/fields/binary/binary_field.js b/addons/web/static/src/views/fields/binary/binary_field.js index 44adb9064c9..6de9d6c31f9 100644 --- a/addons/web/static/src/views/fields/binary/binary_field.js +++ b/addons/web/static/src/views/fields/binary/binary_field.js @@ -8,7 +8,7 @@ import { standardFieldProps } from "../standard_field_props"; import { FileUploader } from "../file_handler"; import { _lt } from "@web/core/l10n/translation"; -import { Component, onWillUpdateProps, useState } from "@odoo/owl"; +import { Component } from "@odoo/owl"; export class BinaryField extends Component { static template = "web.BinaryField"; static components = { @@ -25,20 +25,17 @@ export class BinaryField extends Component { setup() { this.notification = useService("notification"); - this.state = useState({ - fileName: this.props.record.data[this.props.fileNameField] || "", - }); - onWillUpdateProps((nextProps) => { - this.state.fileName = nextProps.record.data[nextProps.fileNameField] || ""; - }); } get fileName() { - return this.state.fileName || this.props.record.data[this.props.name] || ""; + return ( + this.props.record.data[this.props.fileNameField] || + this.props.record.data[this.props.name] || + "" + ); } update({ data, name }) { - this.state.fileName = name || ""; const { fileNameField, record } = this.props; const changes = { [this.props.name]: data || false }; if (fileNameField in record.fields && record.data[fileNameField] !== name) { diff --git a/addons/web/static/src/views/fields/binary/binary_field.xml b/addons/web/static/src/views/fields/binary/binary_field.xml index 92b9adeaee3..2e90c452f61 100644 --- a/addons/web/static/src/views/fields/binary/binary_field.xml +++ b/addons/web/static/src/views/fields/binary/binary_field.xml @@ -56,7 +56,7 @@ - +
      diff --git a/addons/web/static/src/views/fields/color/color_field.js b/addons/web/static/src/views/fields/color/color_field.js index b258402870d..631e98b7e01 100644 --- a/addons/web/static/src/views/fields/color/color_field.js +++ b/addons/web/static/src/views/fields/color/color_field.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import { Component, onWillUpdateProps, useState } from "@odoo/owl"; +import { Component } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { standardFieldProps } from "../standard_field_props"; @@ -10,14 +10,8 @@ export class ColorField extends Component { ...standardFieldProps, }; - setup() { - this.state = useState({ - color: this.props.record.data[this.props.name] || "", - }); - - onWillUpdateProps((nextProps) => { - this.state.color = nextProps.record.data[nextProps.name] || ""; - }); + get color() { + return this.props.record.data[this.props.name] || ""; } } diff --git a/addons/web/static/src/views/fields/color/color_field.xml b/addons/web/static/src/views/fields/color/color_field.xml index dddf2f5af6c..098102da6d9 100644 --- a/addons/web/static/src/views/fields/color/color_field.xml +++ b/addons/web/static/src/views/fields/color/color_field.xml @@ -2,8 +2,8 @@ -
      - +
      +
      diff --git a/addons/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js b/addons/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js index a8f1aa5583c..1f3096f52ba 100644 --- a/addons/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js +++ b/addons/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js @@ -1,8 +1,8 @@ /** @odoo-module **/ +import { evalDomain } from "@web/core/domain"; import { _lt } from "@web/core/l10n/translation"; import { evaluateExpr } from "@web/core/py_js/py"; -import { evalDomain } from "@web/views/utils"; import { registry } from "@web/core/registry"; import { omit } from "@web/core/utils/objects"; diff --git a/addons/web/static/src/views/fields/field.js b/addons/web/static/src/views/fields/field.js index 9e3ce5d3932..b48aac5020d 100644 --- a/addons/web/static/src/views/fields/field.js +++ b/addons/web/static/src/views/fields/field.js @@ -1,19 +1,17 @@ /** @odoo-module **/ -import { makeContext } from "@web/core/context"; -import { Domain } from "@web/core/domain"; +import { Domain, evalDomain } from "@web/core/domain"; import { evaluateExpr } from "@web/core/py_js/py"; import { registry } from "@web/core/registry"; -import { - archParseBoolean, - evalDomain, - getClassNameFromDecoration, - X2M_TYPES, -} from "@web/views/utils"; +import { utils } from "@web/core/ui/ui_service"; +import { getFieldContext } from "@web/model/relational_model/utils"; +import { archParseBoolean, getClassNameFromDecoration, X2M_TYPES } from "@web/views/utils"; import { getTooltipInfo } from "./field_tooltip"; import { Component, xml } from "@odoo/owl"; +const isSmall = utils.isSmall; + const viewRegistry = registry.category("views"); const fieldRegistry = registry.category("fields"); @@ -55,11 +53,63 @@ export function fieldVisualFeedback(field, record, fieldName, fieldInfo) { return { readonly, required: evalDomain(modifiers.required, record.evalContext), - invalid: record.isInvalid(fieldName), + invalid: record.isFieldInvalid(fieldName), empty, }; } +export function getPropertyFieldInfo(propertyField) { + const { name, relatedPropertyField, string, type } = propertyField; + + const fieldInfo = { + name, + string, + type, + widget: type, + options: {}, + modifiers: {}, + attrs: {}, + relatedPropertyField, + + // ??? We don t use it ? But it s in the fieldInfo of the field + context: "{}", + help: undefined, + onChange: false, + forceSave: false, + alwaysInvisible: false, + decorations: {}, + // ??? + }; + + if (type === "many2one" || type === "many2many") { + const { domain, relation } = propertyField; + fieldInfo.relation = relation; + fieldInfo.domain = domain; + + if (relation === "res.users" || relation === "res.partner") { + fieldInfo.widget = + propertyField.type === "many2one" ? "many2one_avatar" : "many2many_tags_avatar"; + } else { + fieldInfo.widget = propertyField.type === "many2one" ? type : "many2many_tags"; + } + } else if (type === "tags") { + fieldInfo.tags = propertyField.tags; + fieldInfo.widget = `property_tags`; + } else if (type === "selection") { + fieldInfo.selection = propertyField.selection; + } + + fieldInfo.field = getFieldFromRegistry(propertyField.type, fieldInfo.widget); + let { relatedFields } = fieldInfo.field; + if (relatedFields) { + if (relatedFields instanceof Function) { + relatedFields = relatedFields({ options: {}, attrs: {} }); + } + fieldInfo.relatedFields = Object.fromEntries(relatedFields.map((f) => [f.name, f])); + } + + return fieldInfo; +} export class Field extends Component { setup() { if (this.props.fieldInfo) { @@ -134,21 +184,7 @@ export class Field extends Component { } const dynamicInfo = { get context() { - const evalContext = record.getEvalContext - ? record.getEvalContext(false) - : record.evalContext; - - const context = {}; - for (const key in record.context) { - if (!key.startsWith("default_") && !key.endsWith("_view_ref")) { - context[key] = record.context[key]; - } - } - - return { - ...context, - ...makeContext([fieldInfo.context], evalContext), - }; + return getFieldContext(record, fieldInfo.name, fieldInfo.context); }, domain() { const evalContext = record.getEvalContext @@ -256,43 +292,59 @@ Field.parseFieldNode = function (node, models, modelName, viewType, jsClass) { fieldInfo.attrs[name] = value; } } + if (name === "id") { + fieldInfo.modifiers.readonly = true; + } + + if (widget === "handle") { + fieldInfo.isHandle = true; + } if (X2M_TYPES.includes(fields[name].type)) { const views = {}; - for (const child of node.children) { - const viewType = child.tagName === "tree" ? "list" : child.tagName; - const { ArchParser } = viewRegistry.get(viewType); - const xmlSerializer = new XMLSerializer(); - const subArch = xmlSerializer.serializeToString(child); - const archInfo = new ArchParser().parse(subArch, models, fields[name].relation); - views[viewType] = { - ...archInfo, - fields: models[fields[name].relation], - }; - fieldInfo.relatedFields = models[fields[name].relation]; - } - - let viewMode = node.getAttribute("mode"); - if (!viewMode) { - if (views.list && !views.kanban) { - viewMode = "list"; - } else if (!views.list && views.kanban) { - viewMode = "kanban"; - } else if (views.list && views.kanban) { - viewMode = "list,kanban"; - } - } else { - viewMode = viewMode.replace("tree", "list"); - } - fieldInfo.viewMode = viewMode; - fieldInfo.views = views; - - let relatedFields = field.relatedFields; + let relatedFields = fieldInfo.field.relatedFields; if (relatedFields) { if (relatedFields instanceof Function) { relatedFields = relatedFields(fieldInfo); } - fieldInfo.relatedFields = Object.fromEntries(relatedFields.map((f) => [f.name, f])); + relatedFields = Object.fromEntries(relatedFields.map((f) => [f.name, f])); + views.default = { fieldNodes: relatedFields, fields: relatedFields }; + fieldInfo.viewMode = "default"; + } else { + for (const child of node.children) { + const viewType = child.tagName === "tree" ? "list" : child.tagName; + const { ArchParser } = viewRegistry.get(viewType); + const xmlSerializer = new XMLSerializer(); + const subArch = xmlSerializer.serializeToString(child); + const archInfo = new ArchParser().parse(subArch, models, fields[name].relation); + views[viewType] = { + ...archInfo, + limit: archInfo.limit || 40, + fields: models[fields[name].relation], + }; + } + + let viewMode = node.getAttribute("mode"); + if (!viewMode) { + if (views.list && !views.kanban) { + viewMode = "list"; + } else if (!views.list && views.kanban) { + viewMode = "kanban"; + } else if (views.list && views.kanban) { + viewMode = isSmall() ? "kanban" : "list"; + } + } else { + if (viewMode.split(",").length !== 1) { + viewMode = isSmall() ? "kanban" : "list"; + } else { + viewMode = viewMode === "tree" ? "list" : viewMode; + } + } + fieldInfo.viewMode = viewMode; + } + if (Object.keys(views).length) { + fieldInfo.relatedFields = models[fields[name].relation]; + fieldInfo.views = views; } } diff --git a/addons/web/static/src/views/fields/font_selection/font_selection_field.js b/addons/web/static/src/views/fields/font_selection/font_selection_field.js index 0ac405a6997..f9a01d6b21d 100644 --- a/addons/web/static/src/views/fields/font_selection/font_selection_field.js +++ b/addons/web/static/src/views/fields/font_selection/font_selection_field.js @@ -49,7 +49,6 @@ export const fontSelectionField = { required: dynamicInfo.required, }; }, - legacySpecialData: "_fetchSpecialRelation", }; registry.category("fields").add("font", fontSelectionField); diff --git a/addons/web/static/src/views/fields/image/image_field.js b/addons/web/static/src/views/fields/image/image_field.js index 16a42a37e55..0bd447934d7 100644 --- a/addons/web/static/src/views/fields/image/image_field.js +++ b/addons/web/static/src/views/fields/image/image_field.js @@ -9,7 +9,7 @@ import { isBinarySize } from "@web/core/utils/binary"; import { FileUploader } from "../file_handler"; import { standardFieldProps } from "../standard_field_props"; -import { Component, useState, onWillUpdateProps } from "@odoo/owl"; +import { Component, useState } from "@odoo/owl"; const { DateTime } = luxon; export const fileTypeMagicWordMap = { @@ -61,15 +61,10 @@ export class ImageField extends Component { this.state = useState({ isValid: true, }); + } - this.rawCacheKey = this.props.record.data.write_date; - onWillUpdateProps((nextProps) => { - const { record } = this.props; - const { record: nextRecord } = nextProps; - if (record.resId !== nextRecord.resId || nextRecord.mode === "readonly") { - this.rawCacheKey = nextRecord.data.write_date; - } - }); + get rawCacheKey() { + return this.props.record.data.write_date; } get sizeStyle() { @@ -103,9 +98,6 @@ export class ImageField extends Component { getUrl(previewFieldName) { if (this.state.isValid && this.props.record.data[this.props.name]) { if (isBinarySize(this.props.record.data[this.props.name])) { - if (!this.rawCacheKey) { - this.rawCacheKey = this.props.record.data.write_date; - } return url("/web/image", { model: this.props.record.resModel, id: this.props.record.resId, @@ -127,8 +119,6 @@ export class ImageField extends Component { } async onFileUploaded(info) { this.state.isValid = true; - // Invalidate the `rawCacheKey`. - this.rawCacheKey = null; if (info.type === 'image/webp') { // Generate alternate sizes and format for reports. const image = document.createElement('img'); diff --git a/addons/web/static/src/views/fields/input_field_hook.js b/addons/web/static/src/views/fields/input_field_hook.js index 0660f24a3d8..b44054d60e7 100644 --- a/addons/web/static/src/views/fields/input_field_hook.js +++ b/addons/web/static/src/views/fields/input_field_hook.js @@ -113,7 +113,11 @@ export function useInputField(params) { * If it is not such a case, we update the field with the new value. */ useEffect(() => { - if (inputRef.el && !isDirty && !component.props.record.isInvalid(component.props.name)) { + if ( + inputRef.el && + !isDirty && + !component.props.record.isFieldInvalid(component.props.name) + ) { inputRef.el.value = params.getValue(); lastSetValue = inputRef.el.value; } @@ -154,10 +158,9 @@ export function useInputField(params) { } if ((val || false) !== (component.props.record.data[component.props.name] || false)) { - const nextValue = inputRef.el.value; + lastSetValue = inputRef.el.value; await component.props.record.update({ [component.props.name]: val }); component.props.record.model.bus.trigger("FIELD_IS_DIRTY", false); - lastSetValue = nextValue; } } } diff --git a/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.js b/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.js index 9c834abe4e8..51732d65a84 100644 --- a/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.js +++ b/addons/web/static/src/views/fields/many2many_binary/many2many_binary_field.js @@ -30,7 +30,12 @@ export class Many2ManyBinaryField extends Component { return this.props.record.fields[this.props.name].string; } get files() { - return this.props.record.data[this.props.name].records.map((record) => record.data); + return this.props.record.data[this.props.name].records.map((record) => { + return { + ...record.data, + id: record.resId, + }; + }); } getUrl(id) { @@ -55,7 +60,7 @@ export class Many2ManyBinaryField extends Component { async onFileRemove(deleteId) { const record = this.props.record.data[this.props.name].records.find( - (record) => record.data.id === deleteId + (record) => record.resId === deleteId ); this.operations.removeRecord(record); } diff --git a/addons/web/static/src/views/fields/many2many_checkboxes/many2many_checkboxes_field.js b/addons/web/static/src/views/fields/many2many_checkboxes/many2many_checkboxes_field.js index e65bedfed48..84b6d3eaa2f 100644 --- a/addons/web/static/src/views/fields/many2many_checkboxes/many2many_checkboxes_field.js +++ b/addons/web/static/src/views/fields/many2many_checkboxes/many2many_checkboxes_field.js @@ -1,21 +1,29 @@ /** @odoo-module **/ +import { Component } from "@odoo/owl"; import { CheckBox } from "@web/core/checkbox/checkbox"; import { _lt } from "@web/core/l10n/translation"; import { registry } from "@web/core/registry"; +import { useSpecialData } from "@web/views/fields/relational_utils"; import { standardFieldProps } from "../standard_field_props"; -import { Component } from "@odoo/owl"; - export class Many2ManyCheckboxesField extends Component { static template = "web.Many2ManyCheckboxesField"; static components = { CheckBox }; static props = { ...standardFieldProps, + domain: { type: Array, optional: true }, }; + setup() { + this.specialData = useSpecialData((orm, props) => { + const { relation } = props.record.fields[props.name]; + return orm.call(relation, "name_search", ["", props.domain]); + }); + } + get items() { - return this.props.record.preloadedData[this.props.name]; + return this.specialData.data; } isSelected(item) { @@ -44,7 +52,11 @@ export const many2ManyCheckboxesField = { displayName: _lt("Checkboxes"), supportedTypes: ["many2many"], isEmpty: () => false, - legacySpecialData: "_fetchSpecialRelation", + extractProps(fieldInfo, dynamicInfo) { + return { + domain: dynamicInfo.domain(), + }; + }, }; registry.category("fields").add("many2many_checkboxes", many2ManyCheckboxesField); @@ -53,8 +65,3 @@ export function preloadMany2ManyCheckboxes(orm, record, fieldName, { domain }) { const field = record.fields[fieldName]; return orm.call(field.relation, "name_search", ["", domain]); } - -registry.category("preloadedData").add("many2many_checkboxes", { - loadOnTypes: ["many2many"], - preload: preloadMany2ManyCheckboxes, -}); diff --git a/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.js b/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.js index 349fb4cb0e9..045fa4ebffd 100644 --- a/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.js +++ b/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.js @@ -271,7 +271,6 @@ export const many2ManyTagsField = { }, ], supportedTypes: ["many2many"], - isSet: (value) => value.count > 0, relatedFields: ({ options }) => { const relatedFields = [{ name: "display_name", type: "char" }]; if (options.color_field) { diff --git a/addons/web/static/src/views/fields/many2one/many2one_field.js b/addons/web/static/src/views/fields/many2one/many2one_field.js index d737d8a9464..c720ac13c04 100644 --- a/addons/web/static/src/views/fields/many2one/many2one_field.js +++ b/addons/web/static/src/views/fields/many2one/many2one_field.js @@ -106,11 +106,9 @@ export class Many2OneField extends Component { const resId = this.value[0]; const fields = ["display_name"]; const { context } = this.props; + // use unity read + relatedFields from Field Component const records = await this.orm.read(this.relation, [resId], fields, { context }); await this.updateRecord(m2oTupleFromData(records[0])); - if (this.props.record.model.root.id !== this.props.record.id) { - this.props.record.switchMode("readonly"); - } }, onClose: () => this.focusInput(), fieldString: this.string, diff --git a/addons/web/static/src/views/fields/progress_bar/progress_bar_field.js b/addons/web/static/src/views/fields/progress_bar/progress_bar_field.js index ca7c78aba27..711896b19d2 100644 --- a/addons/web/static/src/views/fields/progress_bar/progress_bar_field.js +++ b/addons/web/static/src/views/fields/progress_bar/progress_bar_field.js @@ -6,9 +6,8 @@ import { useNumpadDecimal } from "../numpad_decimal_hook"; import { parseFloat } from "../parsers"; import { standardFieldProps } from "../standard_field_props"; -import { Component, onWillUpdateProps, useRef, useState } from "@odoo/owl"; +import { Component, useRef, useState } from "@odoo/owl"; const formatters = registry.category("formatters"); -const parsers = registry.category("parsers"); export class ProgressBarField extends Component { static template = "web.ProgressBarField"; @@ -28,76 +27,66 @@ export class ProgressBarField extends Component { this.root = useRef("numpadDecimal"); this.maxValueRef = useRef("maxValue"); this.currentValueRef = useRef("currentValue"); + + const { currentValueField, maxValueField, name } = this.props; + this.currentValueField = currentValueField ? currentValueField : name; + if (maxValueField) { + this.maxValueField = maxValueField; + } + this.state = useState({ - currentValue: this.getCurrentValue(this.props), - maxValue: this.getMaxValue(this.props), isEditing: false, }); - onWillUpdateProps((nextProps) => { - Object.assign(this.state, { - currentValue: this.getCurrentValue(nextProps), - maxValue: this.getMaxValue(nextProps), - }); - }); } - get isCurrentValueInteger() { - return this.state.currentValue % 1 === 0; - } get isEditable() { return this.props.isEditable && !this.props.readonly; } - get isMaxValueInteger() { - return this.state.maxValue % 1 === 0; - } get isPercentage() { return !this.props.maxValueField || !isNaN(this.props.maxValueField); } - getCurrentValueField(p) { - return typeof p.currentValueField === "string" ? p.currentValueField : p.name; - } - getMaxValueField(p) { - return typeof p.maxValueField === "string" ? p.maxValueField : p.name; + get currentValue() { + return this.props.record.data[this.currentValueField] || 0; } - getCurrentValue(p) { - return p.record.data[this.getCurrentValueField(p)] || 0; - } - getMaxValue(p) { - if (p.maxValueField) { - return p.record.data[p.maxValueField] || 100; - } - return 100; + get maxValue() { + return this.props.record.data[this.maxValueField] || 100; } formatCurrentValue(humanReadable = !this.state.isEditing) { - const formatter = formatters.get(this.isCurrentValueInteger ? "integer" : "float"); - return formatter(this.state.currentValue, { humanReadable }); + const formatter = formatters.get(Number.isInteger(this.currentValue) ? "integer" : "float"); + return formatter(this.currentValue, { humanReadable }); } formatMaxValue(humanReadable = !this.state.isEditing) { - const formatter = formatters.get(this.isMaxValueInteger ? "integer" : "float"); - return formatter(this.state.maxValue, { humanReadable }); + const formatter = formatters.get(Number.isInteger(this.maxValue) ? "integer" : "float"); + return formatter(this.maxValue, { humanReadable }); } - onCurrentValueChange(ev) { + onValueChange(value, fieldName) { let parsedValue; try { - parsedValue = parseFloat(ev.target.value); + parsedValue = parseFloat(value); } catch { this.props.record.setInvalidField(this.props.name); return; } - if (this.isCurrentValueInteger) { + if (this.props.record.fields[fieldName].type === "integer") { parsedValue = Math.floor(parsedValue); } - this.state.currentValue = parsedValue; - this.props.record.update({ [this.getCurrentValueField(this.props)]: parsedValue }); + this.props.record.update({ [fieldName]: parsedValue }); if (this.props.readonly) { this.props.record.save(); } } + onCurrentValueChange(ev) { + this.onValueChange(ev.target.value, this.currentValueField); + } + onMaxValueChange(ev) { + this.onValueChange(ev.target.value, this.maxValueField); + } + onInputBlur() { if ( document.activeElement !== this.maxValueRef.el && @@ -109,40 +98,6 @@ export class ProgressBarField extends Component { onInputFocus() { this.state.isEditing = true; } - onMaxValueChange(ev) { - let parsedValue; - try { - parsedValue = parseFloat(ev.target.value); - } catch { - this.props.record.setInvalidField(this.props.name); - return; - } - - if (this.isMaxValueInteger) { - parsedValue = Math.floor(parsedValue); - } - this.state.maxValue = parsedValue; - this.props.record.update({ [this.getMaxValueField(this.props)]: parsedValue }); - if (this.props.readonly) { - this.props.record.save(); - } - } - onCurrentValueInput(ev) { - const parser = parsers.get(this.isCurrentValueInteger ? "integer" : "float"); - try { - this.state.currentValue = parser(ev.target.value); - } catch { - // pass - } - } - onMaxValueInput(ev) { - const parser = parsers.get(this.isMaxValueInteger ? "integer" : "float"); - try { - this.state.maxValue = parser(ev.target.value); - } catch { - // pass - } - } } export const progressBarField = { diff --git a/addons/web/static/src/views/fields/progress_bar/progress_bar_field.xml b/addons/web/static/src/views/fields/progress_bar/progress_bar_field.xml index a3ae7201445..31f11ac64b6 100644 --- a/addons/web/static/src/views/fields/progress_bar/progress_bar_field.xml +++ b/addons/web/static/src/views/fields/progress_bar/progress_bar_field.xml @@ -4,8 +4,8 @@
      -
      -
      +
      +
      @@ -18,7 +18,6 @@ t-att-value="formatCurrentValue() or ''" t-att-required="props.required" t-on-change="onCurrentValueChange" - t-on-input="onCurrentValueInput" t-on-focus="onInputFocus" t-on-blur="onInputBlur" /> @@ -34,7 +33,6 @@ inputmode="decimal" t-att-value="formatCurrentValue()" t-on-change="onCurrentValueChange" - t-on-input="onCurrentValueInput" t-on-focus="onInputFocus" t-on-blur="onInputBlur" /> @@ -48,7 +46,6 @@ inputmode="decimal" t-att-value="formatMaxValue()" t-on-change="onMaxValueChange" - t-on-input="onMaxValueInput" t-on-focus="onInputFocus" t-on-blur="onInputBlur" /> diff --git a/addons/web/static/src/views/fields/properties/properties_field.js b/addons/web/static/src/views/fields/properties/properties_field.js index bc695f6aa11..86dc201596c 100644 --- a/addons/web/static/src/views/fields/properties/properties_field.js +++ b/addons/web/static/src/views/fields/properties/properties_field.js @@ -91,13 +91,7 @@ export class PropertiesField extends Component { * @returns {array} */ get propertiesList() { - const propertiesValues = (this.props.record.data[this.props.name] || []).map(property => { - if (["date", "datetime"].includes(property.type)) { - // Date and Datetime are immutable luxon objects. - return property; - } - return JSON.parse(JSON.stringify(property)); - }); + const propertiesValues = this.props.record.data[this.props.name] || []; return propertiesValues.filter((definition) => !definition.definition_deleted); } diff --git a/addons/web/static/src/views/fields/radio/radio_field.js b/addons/web/static/src/views/fields/radio/radio_field.js index aa4acb2b2df..e1cb207e787 100644 --- a/addons/web/static/src/views/fields/radio/radio_field.js +++ b/addons/web/static/src/views/fields/radio/radio_field.js @@ -1,10 +1,10 @@ /** @odoo-module **/ -import { registry } from "@web/core/registry"; -import { _lt } from "@web/core/l10n/translation"; -import { standardFieldProps } from "../standard_field_props"; - import { Component } from "@odoo/owl"; +import { _lt } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { useSpecialData } from "@web/views/fields/relational_utils"; +import { standardFieldProps } from "../standard_field_props"; export class RadioField extends Component { static template = "web.RadioField"; @@ -12,6 +12,7 @@ export class RadioField extends Component { ...standardFieldProps, orientation: { type: String, optional: true }, label: { type: String, optional: true }, + domain: { type: Array, optional: true }, }; static defaultProps = { orientation: "vertical", @@ -19,28 +20,34 @@ export class RadioField extends Component { static nextId = 0; - static getItems(fieldName, record) { - switch (record.fields[fieldName].type) { + setup() { + this.type = this.props.record.fields[this.props.name].type; + if (this.type === "many2one") { + this.specialData = useSpecialData(async (orm, props) => { + const { relation } = props.record.fields[props.name]; + const kwargs = { + specification: { display_name: 1 }, + domain: props.domain, + }; + const { records } = await orm.call(relation, "unity_web_search_read", [], kwargs); + return records.map((record) => [record.id, record.display_name]); + }); + } + } + + get items() { + switch (this.type) { case "selection": - return record.fields[fieldName].selection; + return this.props.record.fields[this.props.name].selection; case "many2one": { - const value = record.preloadedData[fieldName] || []; - return value.map((item) => [item.id, item.display_name]); + return this.specialData.data; } default: return []; } } - - setup() { - this.id = `radio_field_${++RadioField.nextId}`; - } - - get items() { - return RadioField.getItems(this.props.name, this.props.record); - } get value() { - switch (this.props.record.fields[this.props.name].type) { + switch (this.type) { case "selection": return this.props.record.data[this.props.name]; case "many2one": @@ -56,7 +63,7 @@ export class RadioField extends Component { * @param {any} value */ onChange(value) { - switch (this.props.record.fields[this.props.name].type) { + switch (this.type) { case "selection": this.props.record.update({ [this.props.name]: value[0] }); break; @@ -79,22 +86,11 @@ export const radioField = { ], supportedTypes: ["many2one", "selection"], isEmpty: (record, fieldName) => record.data[fieldName] === false, - extractProps: ({ options, string }) => ({ + extractProps: ({ options, string }, dynamicInfo) => ({ orientation: options.horizontal ? "horizontal" : "vertical", label: string, + domain: dynamicInfo.domain(), }), - legacySpecialData: "_fetchSpecialMany2ones", }; registry.category("fields").add("radio", radioField); - -export async function preloadRadio(orm, record, fieldName, { domain }) { - const field = record.fields[fieldName]; - const records = await orm.searchRead(field.relation, domain, ["id"]); - return await orm.nameGet(field.relation, [records.map((record) => record.id)]); -} - -registry.category("preloadedData").add("radio", { - loadOnTypes: ["many2one"], - preload: preloadRadio, -}); diff --git a/addons/web/static/src/views/fields/reference/reference_field.js b/addons/web/static/src/views/fields/reference/reference_field.js index dbd09ed21c9..890b7e00ea6 100644 --- a/addons/web/static/src/views/fields/reference/reference_field.js +++ b/addons/web/static/src/views/fields/reference/reference_field.js @@ -2,14 +2,39 @@ import { _lt } from "@web/core/l10n/translation"; import { registry } from "@web/core/registry"; +import { useRecordObserver } from "@web/model/relational_model/utils"; import { many2OneField, Many2OneField } from "../many2one/many2one_field"; -import { Component, onWillUpdateProps, useState } from "@odoo/owl"; +import { Component, useState } from "@odoo/owl"; -function valuesEqual(a, b) { - return a.resId === b.resId && a.resModel === b.resModel; -} +/** + * @typedef ReferenceValue + * @property {string} resModel + * @property {number} resId + * @property {string} displayName + */ +/** + * 1. Reference field is a char field + * 2. Reference widget has model_field prop + * 3. Standard case + */ + +/** + * This class represents a reference field widget. It can be used to display + * a reference field OR a char field. + * The res_model of the relation is defined either by the reference field itself + * or by the model_field prop. + * + * 1) Reference field is a char field + * We have to fetch the display name (name_get) of the referenced record. + * + * 2) Reference widget has model_field prop + * We have to fetch the technical name of the co model. + * + * 3) Standard case + * The value is already in record.data[fieldName] + */ export class ReferenceField extends Component { static template = "web.ReferenceField"; static components = { @@ -17,120 +42,198 @@ export class ReferenceField extends Component { }; static props = { ...Many2OneField.props, - hideModelSelector: { type: Boolean, optional: true }, + hideModel: { type: Boolean, optional: true }, + modelField: { type: String, optional: true }, }; static defaultProps = { ...Many2OneField.defaultProps, }; setup() { + /** @type {{formattedCharValue?: ReferenceValue, modelName?: string}} */ this.state = useState({ - resModel: this.relation, + formattedCharValue: undefined, // Value extracted from reference char field + modelName: undefined, // Name get of the value of the model field }); - - this.currentValue = this.getValue(this.props); - - onWillUpdateProps((nextProps) => { - if ( - valuesEqual(this.currentValue || {}, this.getValue(nextProps) || {}) && - this.state.resModel && - this.getRelation(nextProps) !== this.state.resModel - ) { - nextProps.record.update({ [this.props.name]: false }); - } - this.currentValue = this.getValue(this.props); - }); - } - - getPreloadedData(p) { - return p.record.preloadedData[p.name]; - } - getValue(p) { - if (p.record.fields[p.name].type === "char") { - const pdata = this.getPreloadedData(p); - if (!pdata) { - return null; - } - return { - resModel: pdata.model, - resId: pdata.data.id, - displayName: pdata.data.display_name, - }; + this.currentValue = undefined; + this.currentRelation = this.getRelation(); + if (this._isCharField(this.props)) { + /** Fetch the display name of the record referenced by the field */ + useRecordObserver(async (record) => { + if (this.currentValue !== record.data[this.props.name]) { + this.state.formattedCharValue = await this._fetchReferenceCharData(this.props); + this.currentValue = record.data[this.props.name]; + } + }); + } else if (this.props.modelField) { + /** Fetch the technical name of the co model */ + useRecordObserver(async (record) => { + if (this.currentModelId !== record.data[this.props.modelField]?.[0]) { + this.state.modelName = await this._fetchModelTechnicalName(this.props); + if (this.currentModelId !== undefined) { + record.update({ [this.props.name]: false }); + } + this.currentModelId = record.data[this.props.modelField]?.[0]; + } + }); } else { - return p.record.data[p.name]; + this.currentValue = this.props.record.data[this.props.name]; } } + get m2oProps() { - const value = this.getValue(this.props); + const value = this.getValue(); const p = { ...this.props, - relation: this.relation, + relation: this.getRelation(), value: value && [value.resId, value.displayName], update: this.updateM2O.bind(this), }; - delete p.hideModelSelector; + delete p.hideModel; + delete p.modelField; return p; } get selection() { - if ( - this.props.record.fields[this.props.name].type !== "char" && - !this.props.hideModelSelector - ) { + if (!this._isCharField(this.props) && !this.hideModelSelector) { return this.props.record.fields[this.props.name].selection; } return []; } get relation() { - return this.getRelation(this.props); + return this.getRelation(); } - getRelation(props) { - const modelName = this.getModelName(props); + get hideModelSelector() { + return this.props.hideModel || this.props.modelField; + } + + getRelation() { + const modelName = this.getModelName(); if (modelName) { return modelName; } - const value = this.getValue(props); + const value = this.getValue(); if (value && value.resModel) { return value.resModel; } else { - return this.state && this.state.resModel; + return this.currentRelation; } } - getModelName(p) { - if (p.hideModelSelector && p.record.preloadedData[p.name]) { - return p.record.preloadedData[p.name].modelName; + /** + * @returns {ReferenceValue|false} + */ + getValue() { + if (this._isCharField(this.props)) { + return this.state.formattedCharValue; + } else { + return this.props.record.data[this.props.name]; } - return null; + } + + /** + * @returns {string|undefined} + */ + getModelName() { + return this.hideModelSelector && this.state.modelName; } updateModel(value) { - this.state.resModel = value; + this.currentRelation = value; this.props.record.update({ [this.props.name]: false }); } updateM2O(data) { const value = data[this.props.name]; - if (!this.state.resModel) { - this.state.resModel = this.relation; + if (!this.currentRelation) { + this.currentRelation = this.getRelation(); } this.props.record.update({ [this.props.name]: value && { - resModel: this.state.resModel, + resModel: this.currentRelation, resId: value[0], displayName: value[1], }, }); } + + /** + * Return true if the reference field is a char field. + */ + _isCharField(props) { + return props.record.fields[props.name].type === "char"; + } + + /** + * Fetch special data if the reference field is a char field. + * It fetches the display name of the record. + * + * @returns {Promise<{ resId: number, resModel: string, displayName: string }|false>} + */ + async _fetchReferenceCharData(props) { + const recordData = props.record.data[props.name]; + if (!recordData) { + return false; + } + const [resModel, _resId] = recordData.split(","); + const resId = parseInt(_resId, 10); + if (resModel && resId) { + const { specialDataCaches, orm } = props.record.model; + const key = `__reference__name_get-${recordData}`; + if (!specialDataCaches[key]) { + specialDataCaches[key] = orm.nameGet(resModel, [resId]); + } + const result = await specialDataCaches[key]; + return { + resId, + resModel, + displayName: result[0][1], + }; + } + return false; + } + + /** + * Ensure that the modelField is a many2one to ir.model + */ + _assertMany2OneToIrModel(props) { + const field = props.modelField && props.record.fields[props.modelField]; + if (field && (field.type !== "many2one" || field.relation !== "ir.model")) { + throw new Error( + `The model_field (${props.modelField}) of the reference field ${props.name} must be a many2one('ir.model').` + ); + } + } + + /** + * Fetch the technical name of the model which is selected in the modelField + * props + * + * @returns {Promise} + */ + async _fetchModelTechnicalName(props) { + this._assertMany2OneToIrModel(props); + const record = props.record; + const modelId = record.data[props.modelField]?.[0]; + if (!modelId) { + return false; + } + const { specialDataCaches, orm } = props.record.model; + const key = `__reference__ir_model-${modelId}`; + if (!specialDataCaches[key]) { + specialDataCaches[key] = orm.read("ir.model", [modelId], ["model"]); + } + const result = await specialDataCaches[key]; + return result[0].model; + } } export const referenceField = { component: ReferenceField, displayName: _lt("Reference"), supportedTypes: ["reference", "char"], - legacySpecialData: "_fetchSpecialReference", extractProps({ options }) { /* 1 - @@ -141,7 +244,8 @@ export const referenceField = { We want to display the model selector only in the 4th case. */ const props = many2OneField.extractProps(...arguments); - props.hideModelSelector = !!options.hide_model || !!options.model_field; + props.hideModel = !!options.hide_model; + props.modelField = options.model_field; return props; }, }; diff --git a/addons/web/static/src/views/fields/reference/reference_field.xml b/addons/web/static/src/views/fields/reference/reference_field.xml index 82604929b20..5e444140504 100644 --- a/addons/web/static/src/views/fields/reference/reference_field.xml +++ b/addons/web/static/src/views/fields/reference/reference_field.xml @@ -3,7 +3,7 @@
      - +