diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/views/basic | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views/basic')
| -rw-r--r-- | addons/web/static/src/js/views/basic/basic_controller.js | 883 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/basic/basic_model.js | 5190 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/basic/basic_renderer.js | 926 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/basic/basic_view.js | 454 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/basic/widget_registry.js | 27 |
5 files changed, 7480 insertions, 0 deletions
diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js new file mode 100644 index 00000000..4cbe9027 --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -0,0 +1,883 @@ +odoo.define('web.BasicController', function (require) { +"use strict"; + +/** + * The BasicController is mostly here to share code between views that will use + * a BasicModel (or a subclass). Currently, the BasicViews are the form, list + * and kanban views. + */ + +var AbstractController = require('web.AbstractController'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var FieldManagerMixin = require('web.FieldManagerMixin'); +var TranslationDialog = require('web.TranslationDialog'); + +var _t = core._t; + +var BasicController = AbstractController.extend(FieldManagerMixin, { + events: Object.assign({}, AbstractController.prototype.events, { + 'click .o_content': '_onContentClicked', + }), + custom_events: _.extend({}, AbstractController.prototype.custom_events, FieldManagerMixin.custom_events, { + discard_changes: '_onDiscardChanges', + pager_changed: '_onPagerChanged', + reload: '_onReload', + resequence_records: '_onResequenceRecords', + set_dirty: '_onSetDirty', + load_optional_fields: '_onLoadOptionalFields', + save_optional_fields: '_onSaveOptionalFields', + translate: '_onTranslate', + }), + /** + * @override + * @param {Object} params + * @param {boolean} params.archiveEnabled + * @param {boolean} params.confirmOnDelete + * @param {boolean} params.hasButtons + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.archiveEnabled = params.archiveEnabled; + this.confirmOnDelete = params.confirmOnDelete; + this.hasButtons = params.hasButtons; + FieldManagerMixin.init.call(this, this.model); + this.mode = params.mode || 'readonly'; + // savingDef is used to ensure that we always wait for pending save + // operations to complete before checking if there are changes to + // discard when discardChanges is called + this.savingDef = Promise.resolve(); + // discardingDef is used to ensure that we don't ask twice the user if + // he wants to discard changes, when 'canBeDiscarded' is called several + // times "in parallel" + this.discardingDef = null; + this.viewId = params.viewId; + }, + /** + * @override + * @returns {Promise} + */ + start: async function () { + // add classname to reflect the (absence of) access rights (used to + // correctly display the nocontent helper) + this.$el.toggleClass('o_cannot_create', !this.activeActions.create); + await this._super(...arguments); + }, + /** + * Called each time the controller is dettached into the DOM + */ + on_detach_callback() { + this._super.apply(this, arguments); + this.renderer.resetLocalState(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Determines if we can discard the current changes. If the model is not + * dirty, that is not a problem. However, if it is dirty, we have to ask + * the user for confirmation. + * + * @override + * @param {string} [recordID] - default to main recordID + * @returns {Promise<boolean>} + * resolved if can be discarded, a boolean value is given to tells + * if there is something to discard or not + * rejected otherwise + */ + canBeDiscarded: function (recordID) { + var self = this; + if (this.discardingDef) { + // discard dialog is already open + return this.discardingDef; + } + if (!this.isDirty(recordID)) { + return Promise.resolve(false); + } + + var message = _t("The record has been modified, your changes will be discarded. Do you want to proceed?"); + this.discardingDef = new Promise(function (resolve, reject) { + var dialog = Dialog.confirm(self, message, { + title: _t("Warning"), + confirm_callback: () => { + resolve(true); + self.discardingDef = null; + }, + cancel_callback: () => { + reject(); + self.discardingDef = null; + }, + }); + dialog.on('closed', self.discardingDef, reject); + }); + return this.discardingDef; + }, + /** + * Ask the renderer if all associated field widget are in a valid state for + * saving (valid value and non-empty value for required fields). If this is + * not the case, this notifies the user with a warning containing the names + * of the invalid fields. + * + * Note: changing the style of invalid fields is the renderer's job. + * + * @param {string} [recordID] - default to main recordID + * @return {boolean} + */ + canBeSaved: function (recordID) { + var fieldNames = this.renderer.canBeSaved(recordID || this.handle); + if (fieldNames.length) { + this._notifyInvalidFields(fieldNames); + return false; + } + return true; + }, + /** + * Waits for the mutex to be unlocked and for changes to be saved, then + * calls _.discardChanges. + * This ensures that the confirm dialog isn't displayed directly if there is + * a pending 'write' rpc. + * + * @see _.discardChanges + */ + discardChanges: function (recordID, options) { + return Promise.all([this.mutex.getUnlockedDef(), this.savingDef]) + .then(this._discardChanges.bind(this, recordID || this.handle, options)); + }, + /** + * Method that will be overridden by the views with the ability to have selected ids + * + * @returns {Array} + */ + getSelectedIds: function () { + return []; + }, + /** + * Returns true iff the given recordID (or the main recordID) is dirty. + * + * @param {string} [recordID] - default to main recordID + * @returns {boolean} + */ + isDirty: function (recordID) { + return this.model.isDirty(recordID || this.handle); + }, + /** + * Saves the record whose ID is given if necessary (@see _saveRecord). + * + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @returns {Promise} + * Resolved with the list of field names (whose value has been modified) + * Rejected if the record can't be saved + */ + saveRecord: function (recordID, options) { + var self = this; + // Some field widgets can't detect (all) their changes immediately or + // may have to validate them before notifying them, so we ask them to + // commit their current value before saving. This has to be done outside + // of the mutex protection of saving because commitChanges will trigger + // changes and these are also protected. However, we must wait for the + // mutex to be idle to ensure that onchange RPCs returned before asking + // field widgets to commit their value (and validate it, for instance + // for one2many with required fields). So the actual saving has to be + // done after these changes. Also the commitChanges operation might not + // be synchronous for other reason (e.g. the x2m fields will ask the + // user if some discarding has to be made). This operation must also be + // mutex-protected as commitChanges function of x2m has to be aware of + // all final changes made to a row. + var unlockedMutex = this.mutex.getUnlockedDef() + .then(function () { + return self.renderer.commitChanges(recordID || self.handle); + }) + .then(function () { + return self.mutex.exec(self._saveRecord.bind(self, recordID, options)); + }); + this.savingDef = new Promise(function (resolve) { + unlockedMutex.then(resolve).guardedCatch(resolve); + }); + + return unlockedMutex; + }, + /** + * @override + * @returns {Promise} + */ + update: async function (params, options) { + this.mode = params.mode || this.mode; + return this._super(params, options); + }, + /** + * @override + */ + reload: function (params) { + if (params && params.controllerState) { + if (params.controllerState.currentId) { + params.currentId = params.controllerState.currentId; + } + params.ids = params.controllerState.resIds; + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Does the necessary action when trying to "abandon" a given record (e.g. + * when trying to make a new record readonly without having saved it). By + * default, if the abandoned record is the main view one, the only possible + * action is to leave the current view. Otherwise, it is a x2m line, ask the + * model to remove it. + * + * @private + * @param {string} [recordID] - default to main recordID + */ + _abandonRecord: function (recordID) { + recordID = recordID || this.handle; + if (recordID === this.handle) { + this.trigger_up('history_back'); + } else { + this.model.removeLine(recordID); + } + }, + /** + * We override applyChanges (from the field manager mixin) to protect it + * with a mutex. + * + * @override + */ + _applyChanges: function (dataPointID, changes, event) { + var _super = FieldManagerMixin._applyChanges.bind(this); + return this.mutex.exec(function () { + return _super(dataPointID, changes, event); + }); + }, + /** + * Archive the current selection + * + * @private + * @param {number[]} ids + * @param {boolean} archive + * @returns {Promise} + */ + _archive: async function (ids, archive) { + if (ids.length === 0) { + return Promise.resolve(); + } + if (archive) { + await this.model.actionArchive(ids, this.handle); + } else { + await this.model.actionUnarchive(ids, this.handle); + } + return this.update({}, {reload: false}); + }, + /** + * When the user clicks on a 'action button', this function determines what + * should happen. + * + * @private + * @param {Object} attrs the attrs of the button clicked + * @param {Object} [record] the current state of the view + * @returns {Promise} + */ + _callButtonAction: function (attrs, record) { + record = record || this.model.get(this.handle); + const actionData = Object.assign({}, attrs, { + context: record.getContext({additionalContext: attrs.context || {}}) + }); + const recordData = { + context: record.getContext(), + currentID: record.data.id, + model: record.model, + resIDs: record.res_ids, + }; + return this._executeButtonAction(actionData, recordData); + }, + /** + * Called by the field manager mixin to confirm that a change just occured + * (after that potential onchanges have been applied). + * + * Basically, this only relays the notification to the renderer with the + * new state. + * + * @param {string} id - the id of one of the view's records + * @param {string[]} fields - the changed fields + * @param {OdooEvent} e - the event that triggered the change + * @returns {Promise} + */ + _confirmChange: function (id, fields, e) { + if (e.name === 'discard_changes' && e.target.reset) { + // the target of the discard event is a field widget. In that + // case, we simply want to reset the specific field widget, + // not the full view + return e.target.reset(this.model.get(e.target.dataPointID), e, true); + } + + var state = this.model.get(this.handle); + return this.renderer.confirmChange(state, id, fields, e); + }, + /** + * Ask the user to confirm he wants to save the record + * @private + */ + _confirmSaveNewRecord: function () { + var self = this; + var def = new Promise(function (resolve, reject) { + var message = _t("You need to save this new record before editing the translation. Do you want to proceed?"); + var dialog = Dialog.confirm(self, message, { + title: _t("Warning"), + confirm_callback: resolve.bind(self, true), + cancel_callback: reject, + }); + dialog.on('closed', self, reject); + }); + return def; + }, + /** + * Delete records (and ask for confirmation if necessary) + * + * @param {string[]} ids list of local record ids + */ + _deleteRecords: function (ids) { + var self = this; + function doIt() { + return self.model + .deleteRecords(ids, self.modelName) + .then(self._onDeletedRecords.bind(self, ids)); + } + if (this.confirmOnDelete) { + const message = ids.length > 1 ? + _t("Are you sure you want to delete these records?") : + _t("Are you sure you want to delete this record?"); + Dialog.confirm(this, message, { confirm_callback: doIt }); + } else { + doIt(); + } + }, + /** + * Disables buttons so that they can't be clicked anymore. + * + * @private + */ + _disableButtons: function () { + if (this.$buttons) { + this.$buttons.find('button').attr('disabled', true); + } + }, + /** + * Discards the changes made to the record whose ID is given, if necessary. + * Automatically leaves to default mode for the given record. + * + * @private + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @param {boolean} [options.readonlyIfRealDiscard=false] + * After discarding record changes, the usual option is to make the + * record readonly. However, the action manager calls this function + * at inappropriate times in the current code and in that case, we + * don't want to go back to readonly if there is nothing to discard + * (e.g. when switching record in edit mode in form view, we expect + * the new record to be in edit mode too, but the view manager calls + * this function as the URL changes...) @todo get rid of this when + * the webclient/action_manager's hashchange mechanism is improved. + * @param {boolean} [options.noAbandon=false] + * @returns {Promise} + */ + _discardChanges: function (recordID, options) { + var self = this; + recordID = recordID || this.handle; + options = options || {}; + return this.canBeDiscarded(recordID) + .then(function (needDiscard) { + if (options.readonlyIfRealDiscard && !needDiscard) { + return; + } + self.model.discardChanges(recordID); + if (options.noAbandon) { + return; + } + if (self.model.canBeAbandoned(recordID)) { + self._abandonRecord(recordID); + return; + } + return self._confirmSave(recordID); + }); + }, + /** + * Enables buttons so they can be clicked again. + * + * @private + */ + _enableButtons: function () { + if (this.$buttons) { + this.$buttons.find('button').removeAttr('disabled'); + } + }, + /** + * Executes the action associated with a button + * + * @private + * @param {Object} actionData: the descriptor of the action + * @param {string} actionData.type: the button's action's type, accepts "object" or "action" + * @param {string} actionData.name: the button's action's name + * either the model method's name for type "object" + * or the action's id in database, or xml_id + * @param {string} actionData.context: the action's execution context + * + * @param {Object} recordData: basic information on the current record(s) + * @param {number[]} recordData.resIDs: record ids: + * - on which an object method applies + * - that will be used as active_ids to load an action + * @param {string} recordData.model: model name + * @param {Object} recordData.context: the records' context, will be used to load + * the action, and merged into actionData.context at execution time + * + * @returns {Promise} + */ + async _executeButtonAction(actionData, recordData) { + const prom = new Promise((resolve, reject) => { + this.trigger_up('execute_action', { + action_data: actionData, + env: recordData, + on_closed: () => this.isDestroyed() ? Promise.resolve() : this.reload(), + on_success: resolve, + on_fail: () => this.update({}, { reload: false }).then(reject).guardedCatch(reject) + }); + }); + return this.alive(prom); + }, + /** + * Override to add the current record ID (currentId) and the list of ids + * (resIds) in the current dataPoint to the exported state. + * + * @override + */ + exportState: function () { + var state = this._super.apply(this, arguments); + var env = this.model.get(this.handle, {env: true}); + return _.extend(state, { + currentId: env.currentId, + resIds: env.ids, + }); + }, + /** + * Compute the optional fields local storage key using the given parts. + * + * @param {Object} keyParts + * @param {string} keyParts.viewType view type + * @param {string} [keyParts.relationalField] name of the field with subview + * @param {integer} [keyParts.subViewId] subview id + * @param {string} [keyParts.subViewType] type of the subview + * @param {Object} keyParts.fields fields + * @param {string} keyParts.fields.name field name + * @param {string} keyParts.fields.type field type + * @returns {string} local storage key for optional fields in this view + * @private + */ + _getOptionalFieldsLocalStorageKey: function (keyParts) { + keyParts.model = this.modelName; + keyParts.viewType = this.viewType; + keyParts.viewId = this.viewId; + + var parts = [ + 'model', + 'viewType', + 'viewId', + 'relationalField', + 'subViewType', + 'subViewId', + ]; + + var viewIdentifier = parts.reduce(function (identifier, partName) { + if (partName in keyParts) { + return identifier + ',' + keyParts[partName]; + } + return identifier; + }, 'optional_fields'); + + viewIdentifier = + keyParts.fields.sort(this._nameSortComparer) + .reduce(function (identifier, field) { + return identifier + ',' + field.name; + }, viewIdentifier); + + return viewIdentifier; + }, + /** + * Return the params (currentMinimum, limit and size) to pass to the pager, + * according to the current state. + * + * @private + * @returns {Object} + */ + _getPagingInfo: function (state) { + const isGrouped = state.groupedBy && state.groupedBy.length; + return { + currentMinimum: (isGrouped ? state.groupsOffset : state.offset) + 1, + limit: isGrouped ? state.groupsLimit : state.limit, + size: isGrouped ? state.groupsCount : state.count, + }; + }, + /** + * Return the new actionMenus props. + * + * @override + * @private + */ + _getActionMenuItems: function (state) { + return { + activeIds: this.getSelectedIds(), + context: state.getContext(), + }; + }, + /** + * Sort function used to sort the fields by names, to compute the optional fields keys + * + * @param {Object} left + * @param {Object} right + * @private + */ + _nameSortComparer: function(left, right) { + return left.name < right.name ? -1 : 1; + }, + /** + * Helper function to display a warning that some fields have an invalid + * value. This is used when a save operation cannot be completed. + * + * @private + * @param {string[]} invalidFields - list of field names + */ + _notifyInvalidFields: function (invalidFields) { + var record = this.model.get(this.handle, {raw: true}); + var fields = record.fields; + var warnings = invalidFields.map(function (fieldName) { + var fieldStr = fields[fieldName].string; + return _.str.sprintf('<li>%s</li>', _.escape(fieldStr)); + }); + warnings.unshift('<ul>'); + warnings.push('</ul>'); + this.do_warn(_t("Invalid fields:"), warnings.join('')); + }, + /** + * Hook method, called when record(s) has been deleted. + * + * @see _deleteRecord + * @param {string[]} ids list of deleted ids (basic model local handles) + */ + _onDeletedRecords: function (ids) { + this.update({}); + }, + /** + * Saves the record whose ID is given, if necessary. Automatically leaves + * edit mode for the given record, unless told otherwise. + * + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @param {boolean} [options.stayInEdit=false] + * if true, leave the record in edit mode after save + * @param {boolean} [options.reload=true] + * if true, reload the record after (real) save + * @param {boolean} [options.savePoint=false] + * if true, the record will only be 'locally' saved: its changes + * will move from the _changes key to the data key + * @returns {Promise} + * Resolved with the list of field names (whose value has been modified) + * Rejected if the record can't be saved + */ + _saveRecord: function (recordID, options) { + recordID = recordID || this.handle; + options = _.defaults(options || {}, { + stayInEdit: false, + reload: true, + savePoint: false, + }); + + // Check if the view is in a valid state for saving + // Note: it is the model's job to do nothing if there is nothing to save + if (this.canBeSaved(recordID)) { + var self = this; + var saveDef = this.model.save(recordID, { // Save then leave edit mode + reload: options.reload, + savePoint: options.savePoint, + viewType: options.viewType, + }); + if (!options.stayInEdit) { + saveDef = saveDef.then(function (fieldNames) { + var def = fieldNames.length ? self._confirmSave(recordID) : self._setMode('readonly', recordID); + return def.then(function () { + return fieldNames; + }); + }); + } + return saveDef; + } else { + return Promise.reject("SaveRecord: this.canBeSave is false"); // Cannot be saved + } + }, + /** + * Change the mode for the record associated to the given ID. + * If the given recordID is the view's main one, then the whole view mode is + * changed (@see BasicController.update). + * + * @private + * @param {string} mode - 'readonly' or 'edit' + * @param {string} [recordID] + * @returns {Promise} + */ + _setMode: function (mode, recordID) { + if ((recordID || this.handle) === this.handle) { + return this.update({mode: mode}, {reload: false}).then(function () { + // necessary to allow all sub widgets to use their dimensions in + // layout related activities, such as autoresize on fieldtexts + core.bus.trigger('DOM_updated'); + }); + } + return Promise.resolve(); + }, + /** + * To override such that it returns true iff the primary action button must + * bounce when the user clicked on the given element, according to the + * current state of the view. + * + * @private + * @param {HTMLElement} element the node the user clicked on + * @returns {boolean} + */ + _shouldBounceOnClick: function (/* element */) { + return false; + }, + /** + * Helper method, to get the current environment variables from the model + * and notifies the component chain (by bubbling an event up) + * + * @private + * @param {Object} [newProps={}] + */ + _updateControlPanel: function (newProps = {}) { + const state = this.model.get(this.handle); + const props = Object.assign(newProps, { + actionMenus: this._getActionMenuItems(state), + pager: this._getPagingInfo(state), + title: this.getTitle(), + }); + return this.updateControlPanel(props); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the user clicks on the 'content' part of the controller + * (typically the renderer area). Makes the first primary button in the + * control panel bounce, in some situations (see _shouldBounceOnClick). + * + * @private + * @param {MouseEvent} ev + */ + _onContentClicked(ev) { + if (this.$buttons && this._shouldBounceOnClick(ev.target)) { + this.$buttons.find('.btn-primary:visible:first').odooBounce(); + } + }, + /** + * Called when a list element asks to discard the changes made to one of + * its rows. It can happen with a x2many (if we are in a form view) or with + * a list view. + * + * @private + * @param {OdooEvent} ev + */ + _onDiscardChanges: function (ev) { + var self = this; + ev.stopPropagation(); + var recordID = ev.data.recordID; + this._discardChanges(recordID) + .then(function () { + // TODO this will tell the renderer to rerender the widget that + // asked for the discard but will unfortunately lose the click + // made on another row if any + self._confirmChange(recordID, [ev.data.fieldName], ev) + .then(ev.data.onSuccess).guardedCatch(ev.data.onSuccess); + }) + .guardedCatch(ev.data.onFailure); + }, + /** + * Forces to save directly the changes if the controller is in readonly, + * because in that case the changes come from widgets that are editable even + * in readonly (e.g. Priority). + * + * @private + * @param {OdooEvent} ev + */ + _onFieldChanged: function (ev) { + if (this.mode === 'readonly' && !('force_save' in ev.data)) { + ev.data.force_save = true; + } + FieldManagerMixin._onFieldChanged.apply(this, arguments); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onPagerChanged: async function (ev) { + ev.stopPropagation(); + const { currentMinimum, limit } = ev.data; + const state = this.model.get(this.handle, { raw: true }); + const reloadParams = state.groupedBy && state.groupedBy.length ? { + groupsLimit: limit, + groupsOffset: currentMinimum - 1, + } : { + limit, + offset: currentMinimum - 1, + }; + await this.reload(reloadParams); + // reset the scroll position to the top on page changed only + if (state.limit === limit) { + this.trigger_up('scrollTo', { top: 0 }); + } + }, + /** + * When a reload event triggers up, we need to reload the full view. + * For example, after a form view dialog saved some data. + * + * @todo: rename db_id into handle + * + * @param {OdooEvent} ev + * @param {Object} ev.data + * @param {string} [ev.data.db_id] handle of the data to reload and + * re-render (reload the whole form by default) + * @param {string[]} [ev.data.fieldNames] list of the record's fields to + * reload + * @param {Function} [ev.data.onSuccess] callback executed after reload is resolved + * @param {Function} [ev.data.onFailure] callback executed when reload is rejected + */ + _onReload: function (ev) { + ev.stopPropagation(); // prevent other controllers from handling this request + var data = ev && ev.data || {}; + var handle = data.db_id; + var prom; + if (handle) { + // reload the relational field given its db_id + prom = this.model.reload(handle).then(this._confirmSave.bind(this, handle)); + } else { + // no db_id given, so reload the main record + prom = this.reload({ + fieldNames: data.fieldNames, + keepChanges: data.keepChanges || false, + }); + } + prom.then(ev.data.onSuccess).guardedCatch(ev.data.onFailure); + }, + /** + * Resequence records in the given order. + * + * @private + * @param {OdooEvent} ev + * @param {string[]} ev.data.recordIds + * @param {integer} ev.data.offset + * @param {string} ev.data.handleField + */ + _onResequenceRecords: function (ev) { + ev.stopPropagation(); // prevent other controllers from handling this request + this.trigger_up('mutexify', { + action: async () => { + let state = this.model.get(this.handle); + const resIDs = ev.data.recordIds + .map(recordID => state.data.find(d => d.id === recordID).res_id); + const options = { + offset: ev.data.offset, + field: ev.data.handleField, + }; + await this.model.resequence(this.modelName, resIDs, this.handle, options); + this._updateControlPanel(); + state = this.model.get(this.handle); + return this._updateRendererState(state, { noRender: true }); + }, + }); + }, + /** + * Load the optional columns settings in local storage for this view + * + * @param {OdooEvent} ev + * @param {Object} ev.data.keyParts see _getLocalStorageKey + * @param {function} ev.data.callback function to call with the result + * @private + */ + _onLoadOptionalFields: function (ev) { + var res = this.call( + 'local_storage', + 'getItem', + this._getOptionalFieldsLocalStorageKey(ev.data.keyParts) + ); + ev.data.callback(res); + }, + /** + * Save the optional columns settings in local storage for this view + * + * @param {OdooEvent} ev + * @param {Object} ev.data.keyParts see _getLocalStorageKey + * @param {Array<string>} ev.data.optionalColumnsEnabled list of optional + * field names that have been enabled + * @private + */ + _onSaveOptionalFields: function (ev) { + this.call( + 'local_storage', + 'setItem', + this._getOptionalFieldsLocalStorageKey(ev.data.keyParts), + ev.data.optionalColumnsEnabled + ); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onSetDirty: function (ev) { + ev.stopPropagation(); // prevent other controllers from handling this request + this.model.setDirty(ev.data.dataPointID); + }, + /** + * open the translation view for the current field + * + * @private + * @param {OdooEvent} ev + */ + _onTranslate: async function (ev) { + ev.stopPropagation(); + + if (this.model.isNew(ev.data.id)) { + await this._confirmSaveNewRecord(); + var updatedFields = await this.saveRecord(ev.data.id, { stayInEdit: true }); + await this._confirmChange(ev.data.id, updatedFields, ev); + } + var record = this.model.get(ev.data.id, { raw: true }); + var res_id = record.res_id || record.res_ids[0]; + var result = await this._rpc({ + route: '/web/dataset/call_button', + params: { + model: 'ir.translation', + method: 'translate_fields', + args: [record.model, res_id, ev.data.fieldName], + kwargs: { context: record.getContext() }, + } + }); + + this.translationDialog = new TranslationDialog(this, { + domain: result.domain, + searchName: result.context.search_default_name, + fieldName: ev.data.fieldName, + userLanguageValue: ev.target.value || '', + dataPointID: record.id, + isComingFromTranslationAlert: ev.data.isComingFromTranslationAlert, + isText: result.context.translation_type === 'text', + showSrc: result.context.translation_show_src, + }); + return this.translationDialog.open(); + }, +}); + +return BasicController; +}); diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js new file mode 100644 index 00000000..1e9868e0 --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -0,0 +1,5190 @@ +odoo.define('web.BasicModel', function (require) { +"use strict"; + +/** + * Basic Model + * + * This class contains all the logic necessary to communicate between the + * python models and the web client. More specifically, its job is to give a + * simple unified API to the rest of the web client (in particular, the views and + * the field widgets) to query and modify actual records in db. + * + * From a high level perspective, BasicModel is essentially a hashmap with + * integer keys and some data and metadata object as value. Each object in this + * hashmap represents a piece of data, and can be reloaded and modified by using + * its id as key in many methods. + * + * Here is a description of what those data point look like: + * var dataPoint = { + * _cache: {Object|undefined} + * _changes: {Object|null}, + * aggregateValues: {Object}, + * context: {Object}, + * count: {integer}, + * data: {Object|Object[]}, + * domain: {*[]}, + * fields: {Object}, + * fieldsInfo: {Object}, + * getContext: {function}, + * getDomain: {function}, + * getFieldNames: {function}, + * groupedBy: {string[]}, + * id: {integer}, + * isOpen: {boolean}, + * loadMoreOffset: {integer}, + * limit: {integer}, + * model: {string}, + * offset: {integer}, + * openGroupByDefault: {boolean}, + * orderedBy: {Object[]}, + * orderedResIDs: {integer[]}, + * parentID: {string}, + * rawContext: {Object}, + * relationField: {string}, + * res_id: {integer|null}, + * res_ids: {integer[]}, + * specialData: {Object}, + * _specialDataCache: {Object}, + * static: {boolean}, + * type: {string} 'record' | 'list' + * value: ?, + * }; + * + * Notes: + * - id: is totally unrelated to res_id. id is a web client local concept + * - res_id: if set to a number or a virtual id (a virtual id is a character + * string composed of an integer and has a dash and other information), it + * is an actual id for a record in the server database. If set to + * 'virtual_' + number, it is a record not yet saved (so, in create mode). + * - res_ids: if set, it represent the context in which the data point is actually + * used. For example, a given record in a form view (opened from a list view) + * might have a res_id = 2 and res_ids = [1,2,3] + * - offset: this is mainly used for pagination. Useful when we need to load + * another page, then we can simply change the offset and reload. + * - count is basically the number of records being manipulated. We can't use + * res_ids, because we might have a very large number of records, or a + * domain, and the res_ids would be the current page, not the full set. + * - model is the actual name of a (odoo) model, such as 'res.partner' + * - fields contains the description of all the fields from the model. Note that + * these properties might have been modified by a view (for example, with + * required=true. So, the fields kind of depends of the context of the + * data point. + * - field_names: list of some relevant field names (string). Usually, it + * denotes the fields present in the view. Only those fields should be + * loaded. + * - _cache and _changes are private, they should not leak out of the basicModel + * and be used by anyone else. + * + * Commands: + * commands are the base commands for x2many (0 -> 6), but with a + * slight twist: each [0, _, values] command is augmented with a virtual id: + * it means that when the command is added in basicmodel, it generates an id + * looking like this: 'virtual_' + number, and uses this id to identify the + * element, so it can be edited later. + */ + +var AbstractModel = require('web.AbstractModel'); +var concurrency = require('web.concurrency'); +var Context = require('web.Context'); +var core = require('web.core'); +var Domain = require('web.Domain'); +const pyUtils = require('web.py_utils'); +var session = require('web.session'); +var utils = require('web.utils'); +var viewUtils = require('web.viewUtils'); +var localStorage = require('web.local_storage'); + +var _t = core._t; + +// field types that can be aggregated in grouped views +const AGGREGATABLE_TYPES = ['float', 'integer', 'monetary']; + +var x2ManyCommands = { + // (0, virtualID, {values}) + CREATE: 0, + create: function (virtualID, values) { + delete values.id; + return [x2ManyCommands.CREATE, virtualID || false, values]; + }, + // (1, id, {values}) + UPDATE: 1, + update: function (id, values) { + delete values.id; + return [x2ManyCommands.UPDATE, id, values]; + }, + // (2, id[, _]) + DELETE: 2, + delete: function (id) { + return [x2ManyCommands.DELETE, id, false]; + }, + // (3, id[, _]) removes relation, but not linked record itself + FORGET: 3, + forget: function (id) { + return [x2ManyCommands.FORGET, id, false]; + }, + // (4, id[, _]) + LINK_TO: 4, + link_to: function (id) { + return [x2ManyCommands.LINK_TO, id, false]; + }, + // (5[, _[, _]]) + DELETE_ALL: 5, + delete_all: function () { + return [5, false, false]; + }, + // (6, _, ids) replaces all linked records with provided ids + REPLACE_WITH: 6, + replace_with: function (ids) { + return [6, false, ids]; + } +}; + +var BasicModel = AbstractModel.extend({ + // constants + OPEN_GROUP_LIMIT: 10, // after this limit, groups are automatically folded + + // list of models for which the DataManager's cache should be cleared on + // create, update and delete operations + noCacheModels: [ + 'ir.actions.act_window', + 'ir.filters', + 'ir.ui.view', + ], + + /** + * @override + */ + init: function () { + // this mutex is necessary to make sure some operations are done + // sequentially, for example, an onchange needs to be completed before a + // save is performed. + this.mutex = new concurrency.Mutex(); + + // this array is used to accumulate RPC requests done in the same call + // stack, so that they can be batched in the minimum number of RPCs + this.batchedRPCsRequests = []; + + this.localData = Object.create(null); + // used to generate dataPoint ids. Note that the counter is set to 0 for + // each instance, and this is mandatory for the sample data feature to + // work: we need both the main model and the sample model to generate the + // same datapoint ids for their common data (groups, when there are real + // groups in database), so that we can easily do the mapping between + // real and sample data. + this.__id = 0; + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a default record to a list object. This method actually makes a new + * record with the _makeDefaultRecord method, then adds it to the list object. + * The default record is added in the data directly. This is meant to be used + * by list or kanban controllers (i.e. not for x2manys in form views, as in + * this case, we store changes as commands). + * + * @param {string} listID a valid handle for a list object + * @param {Object} [options] + * @param {string} [options.position=top] if the new record should be added + * on top or on bottom of the list + * @returns {Promise<string>} resolves to the id of the new created record + */ + addDefaultRecord: function (listID, options) { + var self = this; + var list = this.localData[listID]; + var context = _.extend({}, this._getDefaultContext(list), this._getContext(list)); + + var position = (options && options.position) || 'top'; + var params = { + context: context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + parentID: list.id, + position: position, + viewType: list.viewType, + }; + return this._makeDefaultRecord(list.model, params).then(function (id) { + list.count++; + if (position === 'top') { + list.data.unshift(id); + } else { + list.data.push(id); + } + var record = self.localData[id]; + list._cache[record.res_id] = id; + return id; + }); + }, + /** + * Completes the fields and fieldsInfo of a dataPoint with the given ones. + * It is useful for the cases where a record element is shared between + * various views, such as a one2many with a tree and a form view. + * + * @param {string} datapointID a valid element ID (of type 'list' or 'record') + * @param {Object} viewInfo + * @param {Object} viewInfo.fields + * @param {Object} viewInfo.fieldInfo + * @param {string} viewInfo.viewType + * @returns {Promise} resolved when the fieldInfo have been set on the given + * datapoint and all its children, and all rawChanges have been applied + */ + addFieldsInfo: async function (dataPointID, viewInfo) { + var dataPoint = this.localData[dataPointID]; + dataPoint.fields = _.extend({}, dataPoint.fields, viewInfo.fields); + // complete the given fieldInfo with the fields of the main view, so + // that those field will be reloaded if a reload is triggered by the + // secondary view + dataPoint.fieldsInfo = dataPoint.fieldsInfo || {}; + const mainFieldInfo = dataPoint.fieldsInfo[dataPoint[viewInfo.viewType]]; + dataPoint.fieldsInfo[viewInfo.viewType] = _.defaults({}, viewInfo.fieldInfo, mainFieldInfo); + + // Some fields in the new fields info might not be in the previous one, + // so we might have stored changes for them (e.g. coming from onchange + // RPCs), that we haven't been able to process earlier (because those + // fields were unknown at that time). So we now try to process them. + if (dataPoint.type === 'record') { + await this.applyRawChanges(dataPointID, viewInfo.viewType); + } + const proms = []; + const fieldInfo = dataPoint.fieldsInfo[viewInfo.viewType]; + // recursively apply the new field info on sub datapoints + if (dataPoint.type === 'list') { + // case 'list': on all datapoints in the list + Object.values(dataPoint._cache).forEach(subDataPointID => { + proms.push(this.addFieldsInfo(subDataPointID, { + fields: dataPoint.fields, + fieldInfo: dataPoint.fieldsInfo[viewInfo.viewType], + viewType: viewInfo.viewType, + })); + }); + } else { + // case 'record': on datapoints of all x2many fields + const values = _.extend({}, dataPoint.data, dataPoint._changes); + Object.keys(fieldInfo).forEach(fieldName => { + const fieldType = dataPoint.fields[fieldName].type; + if (fieldType === 'one2many' || fieldType === 'many2many') { + const mode = fieldInfo[fieldName].mode; + const views = fieldInfo[fieldName].views; + const x2mDataPointID = values[fieldName]; + if (views[mode] && x2mDataPointID) { + proms.push(this.addFieldsInfo(x2mDataPointID, { + fields: views[mode].fields, + fieldInfo: views[mode].fieldsInfo[mode], + viewType: mode, + })); + } + } + }); + } + return Promise.all(proms); + }, + /** + * Onchange RPCs may return values for fields that are not in the current + * view. Those fields might even be unknown when the onchange returns (e.g. + * in x2manys, we only know the fields that are used in the inner view, but + * not those used in the potential form view opened in a dialog when a sub- + * record is clicked). When this happens, we can't infer their type, so the + * given value can't be processed. It is instead stored in the '_rawChanges' + * key of the record, without any processing. Later on, if this record is + * displayed in another view (e.g. the user clicked on it in the x2many + * list, and the record opens in a dialog), those changes that were left + * behind must be applied. This function applies changes stored in + * '_rawChanges' for a given viewType. + * + * @param {string} recordID local resource id of a record + * @param {string} viewType the current viewType + * @returns {Promise<string>} resolves to the id of the record + */ + applyRawChanges: function (recordID, viewType) { + var record = this.localData[recordID]; + return this._applyOnChange(record._rawChanges, record, { viewType }).then(function () { + return record.id; + }); + }, + /** + * Returns true if a record can be abandoned. + * + * Case for not abandoning the record: + * + * 1. flagged as 'no abandon' (i.e. during a `default_get`, including any + * `onchange` from a `default_get`) + * 2. registered in a list on addition + * + * 2.1. registered as non-new addition + * 2.2. registered as new additon on update + * + * 3. record is not new + * + * Otherwise, the record can be abandoned. + * + * This is useful when discarding changes on this record, as it means that + * we must keep the record even if some fields are invalids (e.g. required + * field is empty). + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + canBeAbandoned: function (id) { + // 1. no drop if flagged + if (this.localData[id]._noAbandon) { + return false; + } + // 2. no drop in a list on "ADD in some cases + var record = this.localData[id]; + var parent = this.localData[record.parentID]; + if (parent) { + var entry = _.findWhere(parent._savePoint, {operation: 'ADD', id: id}); + if (entry) { + // 2.1. no drop on non-new addition in list + if (!entry.isNew) { + return false; + } + // 2.2. no drop on new addition on "UPDATE" + var lastEntry = _.last(parent._savePoint); + if (lastEntry.operation === 'UPDATE' && lastEntry.id === id) { + return false; + } + } + } + // 3. drop new records + return this.isNew(id); + }, + /** + * Delete a list of records, then, if the records have a parent, reload it. + * + * @todo we should remove the deleted records from the localData + * @todo why can't we infer modelName? Because of grouped datapoint + * --> res_id doesn't correspond to the model and we don't have the + * information about the related model + * + * @param {string[]} recordIds list of local resources ids. They should all + * be of type 'record', be of the same model and have the same parent. + * @param {string} modelName mode name used to unlink the records + * @returns {Promise} + */ + deleteRecords: function (recordIds, modelName) { + var self = this; + var records = _.map(recordIds, function (id) { return self.localData[id]; }); + var context = _.extend(records[0].getContext(), session.user_context); + return this._rpc({ + model: modelName, + method: 'unlink', + args: [_.pluck(records, 'res_id')], + context: context, + }) + .then(function () { + _.each(records, function (record) { + var parent = record.parentID && self.localData[record.parentID]; + if (parent && parent.type === 'list') { + parent.data = _.without(parent.data, record.id); + delete self.localData[record.id]; + // Check if we are on last page and all records are deleted from current + // page i.e. if there is no state.data.length then go to previous page + if (!parent.data.length && parent.offset > 0) { + parent.offset = Math.max(parent.offset - parent.limit, 0); + } + } else { + record.res_ids.splice(record.offset, 1); + record.offset = Math.min(record.offset, record.res_ids.length - 1); + record.res_id = record.res_ids[record.offset]; + record.count--; + } + }); + // optionally clear the DataManager's cache + self._invalidateCache(records[0]); + }); + }, + /** + * Discard all changes in a local resource. Basically, it removes + * everything that was stored in a _changes key. + * + * @param {string} id local resource id + * @param {Object} [options] + * @param {boolean} [options.rollback=false] if true, the changes will + * be reset to the last _savePoint, otherwise, they are reset to null + */ + discardChanges: function (id, options) { + options = options || {}; + var element = this.localData[id]; + var isNew = this.isNew(id); + var rollback = 'rollback' in options ? options.rollback : isNew; + var initialOffset = element.offset; + element._domains = {}; + this._visitChildren(element, function (elem) { + if (rollback && elem._savePoint) { + if (elem._savePoint instanceof Array) { + elem._changes = elem._savePoint.slice(0); + } else { + elem._changes = _.extend({}, elem._savePoint); + } + elem._isDirty = !isNew; + } else { + elem._changes = null; + elem._isDirty = false; + } + elem.offset = 0; + if (elem.tempLimitIncrement) { + elem.limit -= elem.tempLimitIncrement; + delete elem.tempLimitIncrement; + } + }); + element.offset = initialOffset; + }, + /** + * Duplicate a record (by calling the 'copy' route) + * + * @param {string} recordID id for a local resource + * @returns {Promise<string>} resolves to the id of duplicate record + */ + duplicateRecord: function (recordID) { + var self = this; + var record = this.localData[recordID]; + var context = this._getContext(record); + return this._rpc({ + model: record.model, + method: 'copy', + args: [record.data.id], + context: context, + }) + .then(function (res_id) { + var index = record.res_ids.indexOf(record.res_id); + record.res_ids.splice(index + 1, 0, res_id); + return self.load({ + fieldsInfo: record.fieldsInfo, + fields: record.fields, + modelName: record.model, + res_id: res_id, + res_ids: record.res_ids.slice(0), + viewType: record.viewType, + context: context, + }); + }); + }, + /** + * For list resources, this freezes the current records order. + * + * @param {string} listID a valid element ID of type list + */ + freezeOrder: function (listID) { + var list = this.localData[listID]; + if (list.type === 'record') { + return; + } + list = this._applyX2ManyOperations(list); + this._sortList(list); + this.localData[listID].orderedResIDs = list.res_ids; + }, + /** + * The __get method first argument is the handle returned by the load method. + * It is optional (the handle can be undefined). In some case, it makes + * sense to use the handle as a key, for example the BasicModel holds the + * data for various records, each with its local ID. + * + * synchronous method, it assumes that the resource has already been loaded. + * + * @param {string} id local id for the resource + * @param {any} options + * @param {boolean} [options.env=false] if true, will only return res_id + * (if record) or res_ids (if list) + * @param {boolean} [options.raw=false] if true, will not follow relations + * @returns {Object} + */ + __get: function (id, options) { + var self = this; + options = options || {}; + + if (!(id in this.localData)) { + return null; + } + + var element = this.localData[id]; + + if (options.env) { + var env = { + ids: element.res_ids ? element.res_ids.slice(0) : [], + }; + if (element.type === 'record') { + env.currentId = this.isNew(element.id) ? undefined : element.res_id; + } + return env; + } + + if (element.type === 'record') { + var data = _.extend({}, element.data, element._changes); + var relDataPoint; + for (var fieldName in data) { + var field = element.fields[fieldName]; + if (data[fieldName] === null) { + data[fieldName] = false; + } + if (!field) { + continue; + } + + // get relational datapoint + if (field.type === 'many2one') { + if (options.raw) { + relDataPoint = this.localData[data[fieldName]]; + data[fieldName] = relDataPoint ? relDataPoint.res_id : false; + } else { + data[fieldName] = this.__get(data[fieldName]) || false; + } + } else if (field.type === 'reference') { + if (options.raw) { + relDataPoint = this.localData[data[fieldName]]; + data[fieldName] = relDataPoint ? + relDataPoint.model + ',' + relDataPoint.res_id : + false; + } else { + data[fieldName] = this.__get(data[fieldName]) || false; + } + } else if (field.type === 'one2many' || field.type === 'many2many') { + if (options.raw) { + if (typeof data[fieldName] === 'string') { + relDataPoint = this.localData[data[fieldName]]; + relDataPoint = this._applyX2ManyOperations(relDataPoint); + data[fieldName] = relDataPoint.res_ids; + } else { + // no datapoint has been created yet (because the loading of relational + // data has been batched, and hasn't started yet), so the value is still + // the list of ids in the relation + data[fieldName] = data[fieldName] || []; + } + } else { + data[fieldName] = this.__get(data[fieldName]) || []; + } + } + } + var record = { + context: _.extend({}, element.context), + count: element.count, + data: data, + domain: element.domain.slice(0), + evalModifiers: element.evalModifiers, + fields: element.fields, + fieldsInfo: element.fieldsInfo, + getContext: element.getContext, + getDomain: element.getDomain, + getFieldNames: element.getFieldNames, + id: element.id, + isDirty: element.isDirty, + limit: element.limit, + model: element.model, + offset: element.offset, + ref: element.ref, + res_ids: element.res_ids.slice(0), + specialData: _.extend({}, element.specialData), + type: 'record', + viewType: element.viewType, + }; + + if (!this.isNew(element.id)) { + record.res_id = element.res_id; + } + var evalContext; + Object.defineProperty(record, 'evalContext', { + get: function () { + evalContext = evalContext || self._getEvalContext(element); + return evalContext; + }, + }); + return record; + } + + // apply potential changes (only for x2many lists) + element = this._applyX2ManyOperations(element); + this._sortList(element); + + if (!element.orderedResIDs && element._changes) { + _.each(element._changes, function (change) { + if (change.operation === 'ADD' && change.isNew) { + element.data = _.without(element.data, change.id); + if (change.position === 'top') { + element.data.unshift(change.id); + } else { + element.data.push(change.id); + } + } + }); + } + + var list = { + aggregateValues: _.extend({}, element.aggregateValues), + context: _.extend({}, element.context), + count: element.count, + data: _.map(element.data, function (elemID) { + return self.__get(elemID, options); + }), + domain: element.domain.slice(0), + fields: element.fields, + getContext: element.getContext, + getDomain: element.getDomain, + getFieldNames: element.getFieldNames, + groupedBy: element.groupedBy, + groupsCount: element.groupsCount, + groupsLimit: element.groupsLimit, + groupsOffset: element.groupsOffset, + id: element.id, + isDirty: element.isDirty, + isOpen: element.isOpen, + isSample: this.isSampleModel, + limit: element.limit, + model: element.model, + offset: element.offset, + orderedBy: element.orderedBy, + res_id: element.res_id, + res_ids: element.res_ids.slice(0), + type: 'list', + value: element.value, + viewType: element.viewType, + }; + if (element.fieldsInfo) { + list.fieldsInfo = element.fieldsInfo; + } + return list; + }, + /** + * Generate default values for a given record. Those values are stored in + * the '_changes' key of the record. For relational fields, sub-dataPoints + * are created, and missing relational data is fetched. + * Typically, this function is called when a new record is created. It may + * also be called when a one2many subrecord is open in a form view (dialog), + * to generate the default values for the fields displayed in the o2m form + * view, but not in the list or kanban (mainly to correctly create + * sub-dataPoints for relational fields). + * + * @param {string} recordID local id for a record + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @param {Array} [options.fieldNames] list of field names for which a + * default value must be generated (used to complete the values dict) + * @returns {Promise} + */ + generateDefaultValues(recordID, options = {}) { + const record = this.localData[recordID]; + const viewType = options.viewType || record.viewType; + const fieldNames = options.fieldNames || Object.keys(record.fieldsInfo[viewType]); + const numericFields = ['float', 'integer', 'monetary']; + const proms = []; + record._changes = record._changes || {}; + fieldNames.forEach(fieldName => { + record.data[fieldName] = null; + if (!(fieldName in record._changes)) { + const field = record.fields[fieldName]; + if (numericFields.includes(field.type)) { + record._changes[fieldName] = 0; + } else if (field.type === 'one2many' || field.type === 'many2many') { + proms.push(this._processX2ManyCommands(record, fieldName, [], options)); + } else { + record._changes[fieldName] = null; + } + } + }); + return Promise.all(proms); + }, + /** + * Returns the current display_name for the record. + * + * @param {string} id the localID for a valid record element + * @returns {string} + */ + getName: function (id) { + var record = this.localData[id]; + var returnValue = ''; + if (record._changes && 'display_name' in record._changes) { + returnValue = record._changes.display_name; + } + else if ('display_name' in record.data) { + returnValue = record.data.display_name; + } + return returnValue || _t("New"); + }, + /** + * Returns true if a record is dirty. A record is considered dirty if it has + * some unsaved changes, marked by the _isDirty property on the record or + * one of its subrecords. + * + * @param {string} id - the local resource id + * @returns {boolean} + */ + isDirty: function (id) { + var isDirty = false; + this._visitChildren(this.localData[id], function (r) { + if (r._isDirty) { + isDirty = true; + } + }); + return isDirty; + }, + /** + * Returns true iff the datapoint is of type list and either: + * - is not grouped, and contains no records + * - is grouped, and contains columns, but all columns are empty + * In these cases, we will generate sample data to display, instead of an + * empty state. + * + * @override + */ + _isEmpty(dataPointID) { + const dataPoint = this.localData[dataPointID]; + if (dataPoint.type === 'list') { + const hasRecords = dataPoint.count === 0; + if (dataPoint.groupedBy.length) { + return dataPoint.data.length > 0 && hasRecords; + } else { + return hasRecords; + } + } + return false; + }, + /** + * Check if a localData is new, meaning if it is in the process of being + * created and no actual record exists in db. Note: if the localData is not + * of the "record" type, then it is always considered as not new. + * + * Note: A virtual id is a character string composed of an integer and has + * a dash and other information. + * E.g: in calendar, the recursive event have virtual id linked to a real id + * virtual event id "23-20170418020000" is linked to the event id 23 + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + isNew: function (id) { + var data = this.localData[id]; + if (data.type !== "record") { + return false; + } + var res_id = data.res_id; + if (typeof res_id === 'number') { + return false; + } else if (typeof res_id === 'string' && /^[0-9]+-/.test(res_id)) { + return false; + } + return true; + }, + /** + * Main entry point, the goal of this method is to fetch and process all + * data (following relations if necessary) for a given record/list. + * + * @todo document all params + * + * @private + * @param {any} params + * @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field + * @param {Object} params.fields contains the description of each field + * @param {string} [params.type] 'record' or 'list' + * @param {string} [params.recordID] an ID for an existing resource. + * @returns {Promise<string>} resolves to a local id, or handle + */ + __load: async function (params) { + await this._super(...arguments); + params.type = params.type || (params.res_id !== undefined ? 'record' : 'list'); + // FIXME: the following seems only to be used by the basic_model_tests + // so it should probably be removed and the tests should be adapted + params.viewType = params.viewType || 'default'; + if (!params.fieldsInfo) { + var fieldsInfo = {}; + for (var fieldName in params.fieldNames) { + fieldsInfo[params.fieldNames[fieldName]] = {}; + } + params.fieldsInfo = {}; + params.fieldsInfo[params.viewType] = fieldsInfo; + } + + if (params.type === 'record' && params.res_id === undefined) { + params.allowWarning = true; + return this._makeDefaultRecord(params.modelName, params); + } + var dataPoint = this._makeDataPoint(params); + return this._load(dataPoint).then(function () { + return dataPoint.id; + }); + }, + /** + * Returns the list of res_ids for a given list of local ids. + * + * @param {string[]} localIds + * @returns {integer[]} + */ + localIdsToResIds: function (localIds) { + return localIds.map(localId => this.localData[localId].res_id); + }, + /** + * This helper method is designed to help developpers that want to use a + * field widget outside of a view. In that case, we want a way to create + * data without actually performing a fetch. + * + * @param {string} model name of the model + * @param {Object[]} fields a description of field properties + * @param {Object} [fieldInfo] various field info that we want to set + * @returns {Promise<string>} the local id for the created resource + */ + makeRecord: function (model, fields, fieldInfo) { + var self = this; + var defs = []; + var record_fields = {}; + _.each(fields, function (field) { + record_fields[field.name] = _.pick(field, 'type', 'relation', 'domain', 'selection'); + }); + fieldInfo = fieldInfo || {}; + var fieldsInfo = {}; + fieldsInfo.default = {}; + _.each(fields, function (field) { + fieldsInfo.default[field.name] = fieldInfo[field.name] || {}; + }); + var record = this._makeDataPoint({ + modelName: model, + fields: record_fields, + fieldsInfo: fieldsInfo, + viewType: 'default', + }); + _.each(fields, function (field) { + var dataPoint; + record.data[field.name] = null; + if (field.type === 'many2one') { + if (field.value) { + var id = _.isArray(field.value) ? field.value[0] : field.value; + var display_name = _.isArray(field.value) ? field.value[1] : undefined; + dataPoint = self._makeDataPoint({ + modelName: field.relation, + data: { + id: id, + display_name: display_name, + }, + parentID: record.id, + }); + record.data[field.name] = dataPoint.id; + if (display_name === undefined) { + defs.push(self._fetchNameGet(dataPoint)); + } + } + } else if (field.type === 'reference' && field.value) { + const ref = field.value.split(','); + dataPoint = self._makeDataPoint({ + context: record.context, + data: { id: parseInt(ref[1], 10) }, + modelName: ref[0], + parentID: record.id, + }); + defs.push(self._fetchNameGet(dataPoint)); + record.data[field.name] = dataPoint.id; + } else if (field.type === 'one2many' || field.type === 'many2many') { + var relatedFieldsInfo = {}; + relatedFieldsInfo.default = {}; + _.each(field.fields, function (field) { + relatedFieldsInfo.default[field.name] = {}; + }); + var dpParams = { + fieldsInfo: relatedFieldsInfo, + modelName: field.relation, + parentID: record.id, + static: true, + type: 'list', + viewType: 'default', + }; + var needLoad = false; + // As value, you could either pass: + // - a list of ids related to the record + // - a list of object + // We only need to load the datapoint in the first case. + if (field.value && field.value.length) { + if (_.isObject(field.value[0])) { + dpParams.res_ids = _.pluck(field.value, 'id'); + dataPoint = self._makeDataPoint(dpParams); + _.each(field.value, function (data) { + var recordDP = self._makeDataPoint({ + data: data, + modelName: field.relation, + parentID: dataPoint.id, + type: 'record', + }); + dataPoint.data.push(recordDP.id); + dataPoint._cache[recordDP.res_id] = recordDP.id; + }); + } else { + dpParams.res_ids = field.value; + dataPoint = self._makeDataPoint(dpParams); + needLoad = true; + } + } else { + dpParams.res_ids = []; + dataPoint = self._makeDataPoint(dpParams); + } + + if (needLoad) { + defs.push(self._load(dataPoint)); + } + record.data[field.name] = dataPoint.id; + } else if (field.value) { + record.data[field.name] = field.value; + } + }); + return Promise.all(defs).then(function () { + return record.id; + }); + }, + /** + * This is an extremely important method. All changes in any field go + * through this method. It will then apply them in the local state, check + * if onchanges needs to be applied, actually do them if necessary, then + * resolves with the list of changed fields. + * + * @param {string} record_id + * @param {Object} changes a map field => new value + * @param {Object} [options] will be transferred to the applyChange method + * @see _applyChange + * @returns {Promise<string[]>} list of changed fields + */ + notifyChanges: function (record_id, changes, options) { + return this.mutex.exec(this._applyChange.bind(this, record_id, changes, options)); + }, + /** + * Reload all data for a given resource. At any time there is at most one + * reload operation active. + * + * @private + * @param {string} id local id for a resource + * @param {Object} [options] + * @param {boolean} [options.keepChanges=false] if true, doesn't discard the + * changes on the record before reloading it + * @returns {Promise<string>} resolves to the id of the resource + */ + __reload: async function (id, options) { + await this._super(...arguments); + return this.mutex.exec(this._reload.bind(this, id, options)); + }, + /** + * In some case, we may need to remove an element from a list, without going + * through the notifyChanges machinery. The motivation for this is when the + * user click on 'Add a line' in a field one2many with a required field, + * then clicks somewhere else. The new line need to be discarded, but we + * don't want to trigger a real notifyChanges (no need for that, and also, + * we don't want to rerender the UI). + * + * @param {string} elementID some valid element id. It is necessary that the + * corresponding element has a parent. + */ + removeLine: function (elementID) { + var record = this.localData[elementID]; + var parent = this.localData[record.parentID]; + if (parent.static) { + // x2Many case: the new record has been stored in _changes, as a + // command so we remove the command(s) related to that record + parent._changes = _.filter(parent._changes, function (change) { + if (change.id === elementID && + change.operation === 'ADD' && // For now, only an ADD command increases limits + parent.tempLimitIncrement) { + // The record will be deleted from the _changes. + // So we won't be passing into the logic of _applyX2ManyOperations anymore + // implying that we have to cancel out the effects of an ADD command here + parent.tempLimitIncrement--; + parent.limit--; + } + return change.id !== elementID; + }); + } else { + // main list view case: the new record is in data + parent.data = _.without(parent.data, elementID); + parent.count--; + } + }, + /** + * Resequences records. + * + * @param {string} modelName the resIDs model + * @param {Array<integer>} resIDs the new sequence of ids + * @param {string} parentID the localID of the parent + * @param {object} [options] + * @param {integer} [options.offset] + * @param {string} [options.field] the field name used as sequence + * @returns {Promise<string>} resolves to the local id of the parent + */ + resequence: function (modelName, resIDs, parentID, options) { + options = options || {}; + if ((resIDs.length <= 1)) { + return Promise.resolve(parentID); // there is nothing to sort + } + var self = this; + var data = this.localData[parentID]; + var params = { + model: modelName, + ids: resIDs, + }; + if (options.offset) { + params.offset = options.offset; + } + if (options.field) { + params.field = options.field; + } + return this._rpc({ + route: '/web/dataset/resequence', + params: params, + }) + .then(function (wasResequenced) { + if (!wasResequenced) { + // the field on which the resequence was triggered does not + // exist, so no resequence happened server-side + return Promise.resolve(); + } + var field = params.field ? params.field : 'sequence'; + + return self._rpc({ + model: modelName, + method: 'read', + args: [resIDs, [field]], + }).then(function (records) { + if (data.data.length) { + var dataType = self.localData[data.data[0]].type; + if (dataType === 'record') { + _.each(data.data, function (dataPoint) { + var recordData = self.localData[dataPoint].data; + var inRecords = _.findWhere(records, {id: recordData.id}); + if (inRecords) { + recordData[field] = inRecords[field]; + } + }); + data.data = _.sortBy(data.data, function (d) { + return self.localData[d].data[field]; + }); + } + if (dataType === 'list') { + data.data = _.sortBy(data.data, function (d) { + return _.indexOf(resIDs, self.localData[d].res_id) + }); + } + } + data.res_ids = []; + _.each(data.data, function (d) { + var dataPoint = self.localData[d]; + if (dataPoint.type === 'record') { + data.res_ids.push(dataPoint.res_id); + } else { + data.res_ids = data.res_ids.concat(dataPoint.res_ids); + } + }); + self._updateParentResIDs(data); + return parentID; + }) + }); + }, + /** + * Save a local resource, if needed. This is a complicated operation, + * - it needs to check all changes, + * - generate commands for x2many fields, + * - call the /create or /write method according to the record status + * - After that, it has to reload all data, in case something changed, server side. + * + * @param {string} recordID local resource + * @param {Object} [options] + * @param {boolean} [options.reload=true] if true, data will be reloaded + * @param {boolean} [options.savePoint=false] if true, the record will only + * be 'locally' saved: its changes written in a _savePoint key that can + * be restored later by call discardChanges with option rollback to true + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {Promise} + * Resolved with the list of field names (whose value has been modified) + */ + save: function (recordID, options) { + var self = this; + return this.mutex.exec(function () { + options = options || {}; + var record = self.localData[recordID]; + if (options.savePoint) { + self._visitChildren(record, function (rec) { + var newValue = rec._changes || rec.data; + if (newValue instanceof Array) { + rec._savePoint = newValue.slice(0); + } else { + rec._savePoint = _.extend({}, newValue); + } + }); + + // save the viewType of edition, so that the correct readonly modifiers + // can be evaluated when the record will be saved + for (let fieldName in (record._changes || {})) { + record._editionViewType[fieldName] = options.viewType; + } + } + var shouldReload = 'reload' in options ? options.reload : true; + var method = self.isNew(recordID) ? 'create' : 'write'; + if (record._changes) { + // id never changes, and should not be written + delete record._changes.id; + } + var changes = self._generateChanges(record, {viewType: options.viewType, changesOnly: method !== 'create'}); + + // id field should never be written/changed + delete changes.id; + + if (method === 'create') { + var fieldNames = record.getFieldNames(); + _.each(fieldNames, function (name) { + if (changes[name] === null) { + delete changes[name]; + } + }); + } + + var prom = new Promise(function (resolve, reject) { + var changedFields = Object.keys(changes); + + if (options.savePoint) { + resolve(changedFields); + return; + } + + // in the case of a write, only perform the RPC if there are changes to save + if (method === 'create' || changedFields.length) { + var args = method === 'write' ? [[record.data.id], changes] : [changes]; + self._rpc({ + model: record.model, + method: method, + args: args, + context: record.getContext(), + }).then(function (id) { + if (method === 'create') { + record.res_id = id; // create returns an id, write returns a boolean + record.data.id = id; + record.offset = record.res_ids.length; + record.res_ids.push(id); + record.count++; + } + + var _changes = record._changes; + + // Erase changes as they have been applied + record._changes = {}; + + // Optionally clear the DataManager's cache + self._invalidateCache(record); + + self.unfreezeOrder(record.id); + + // Update the data directly or reload them + if (shouldReload) { + self._fetchRecord(record).then(function () { + resolve(changedFields); + }); + } else { + _.extend(record.data, _changes); + resolve(changedFields); + } + }).guardedCatch(reject); + } else { + resolve(changedFields); + } + }); + prom.then(function () { + record._isDirty = false; + }); + return prom; + }); + }, + /** + * Manually sets a resource as dirty. This is used to notify that a field + * has been modified, but with an invalid value. In that case, the value is + * not sent to the basic model, but the record should still be flagged as + * dirty so that it isn't discarded without any warning. + * + * @param {string} id a resource id + */ + setDirty: function (id) { + this.localData[id]._isDirty = true; + }, + /** + * For list resources, this changes the orderedBy key. + * + * @param {string} list_id id for the list resource + * @param {string} fieldName valid field name + * @returns {Promise} + */ + setSort: function (list_id, fieldName) { + var list = this.localData[list_id]; + if (list.type === 'record') { + return; + } else if (list._changes) { + _.each(list._changes, function (change) { + delete change.isNew; + }); + } + if (list.orderedBy.length === 0) { + list.orderedBy.push({name: fieldName, asc: true}); + } else if (list.orderedBy[0].name === fieldName){ + if (!list.orderedResIDs) { + list.orderedBy[0].asc = !list.orderedBy[0].asc; + } + } else { + var orderedBy = _.reject(list.orderedBy, function (o) { + return o.name === fieldName; + }); + list.orderedBy = [{name: fieldName, asc: true}].concat(orderedBy); + } + + list.orderedResIDs = null; + if (list.static) { + // sorting might require to fetch the field for records where the + // sort field is still unknown (i.e. on other pages for example) + return this._fetchUngroupedList(list); + } + return Promise.resolve(); + }, + /** + * For a given resource of type 'record', get the active field, if any. + * + * Since the ORM can support both `active` and `x_active` fields for + * the archiving mechanism, check if any such field exists and prioritize + * them. The `active` field should always take priority over its custom + * version. + * + * @param {Object} record local resource + * @returns {String|undefined} the field name to use for archiving purposes + * ('active', 'x_active') or undefined if no such field is present + */ + getActiveField: function (record) { + const fields = Object.keys(record.fields); + const has_active = fields.includes('active'); + if (has_active) { + return 'active'; + } + const has_x_active = fields.includes('x_active'); + return has_x_active?'x_active':undefined + }, + /** + * Toggle the active value of given records (to archive/unarchive them) + * + * @param {Array} recordIDs local ids of the records to (un)archive + * @param {boolean} value false to archive, true to unarchive (value of the active field) + * @param {string} parentID id of the parent resource to reload + * @returns {Promise<string>} resolves to the parent id + */ + toggleActive: function (recordIDs, parentID) { + var self = this; + var parent = this.localData[parentID]; + var resIDs = _.map(recordIDs, function (recordID) { + return self.localData[recordID].res_id; + }); + return this._rpc({ + model: parent.model, + method: 'toggle_active', + args: [resIDs], + }) + .then(function (action) { + // optionally clear the DataManager's cache + self._invalidateCache(parent); + if (!_.isEmpty(action)) { + return self.do_action(action, { + on_close: function () { + return self.trigger_up('reload'); + } + }); + } else { + return self.reload(parentID); + } + }); + }, + /** + * Archive the given records + * + * @param {integer[]} resIDs ids of the records to archive + * @param {string} parentID id of the parent resource to reload + * @returns {Promise<string>} resolves to the parent id + */ + actionArchive: function (resIDs, parentID) { + var self = this; + var parent = this.localData[parentID]; + return this._rpc({ + model: parent.model, + method: 'action_archive', + args: [resIDs], + }) + .then(function (action) { + // optionally clear the DataManager's cache + self._invalidateCache(parent); + if (!_.isEmpty(action)) { + return new Promise(function (resolve, reject) { + self.do_action(action, { + on_close: function (result) { + return self.trigger_up('reload', { + onSuccess: resolve, + }); + } + }); + }); + } else { + return self.reload(parentID); + } + }).then(function (datapoint) { + // if there are no records to display and we are not on first page(we check it + // by checking offset is greater than limit i.e. we are not on first page) + // reason for adding logic after reload to make sure there is no records after operation + if (parent && parent.type === 'list' && !parent.data.length && parent.offset > 0) { + parent.offset = Math.max(parent.offset - parent.limit, 0); + return self.reload(parentID); + } + return datapoint; + }); + }, + /** + * Unarchive the given records + * + * @param {integer[]} resIDs ids of the records to unarchive + * @param {string} parentID id of the parent resource to reload + * @returns {Promise<string>} resolves to the parent id + */ + actionUnarchive: function (resIDs, parentID) { + var self = this; + var parent = this.localData[parentID]; + return this._rpc({ + model: parent.model, + method: 'action_unarchive', + args: [resIDs], + }) + .then(function (action) { + // optionally clear the DataManager's cache + self._invalidateCache(parent); + if (!_.isEmpty(action)) { + return new Promise(function (resolve, reject) { + self.do_action(action, { + on_close: function () { + return self.trigger_up('reload', { + onSuccess: resolve, + }); + } + }); + }); + } else { + return self.reload(parentID); + } + }).then(function (datapoint) { + // if there are no records to display and we are not on first page(we check it + // by checking offset is greater than limit i.e. we are not on first page) + // reason for adding logic after reload to make sure there is no records after operation + if (parent && parent.type === 'list' && !parent.data.length && parent.offset > 0) { + parent.offset = Math.max(parent.offset - parent.limit, 0); + return self.reload(parentID); + } + return datapoint; + }); + }, + /** + * Toggle (open/close) a group in a grouped list, then fetches relevant + * data + * + * @param {string} groupId + * @returns {Promise<string>} resolves to the group id + */ + toggleGroup: function (groupId) { + var self = this; + var group = this.localData[groupId]; + if (group.isOpen) { + group.isOpen = false; + group.data = []; + group.res_ids = []; + group.offset = 0; + this._updateParentResIDs(group); + return Promise.resolve(groupId); + } + if (!group.isOpen) { + group.isOpen = true; + var def; + if (group.count > 0) { + def = this._load(group).then(function () { + self._updateParentResIDs(group); + }); + } + return Promise.resolve(def).then(function () { + return groupId; + }); + } + }, + /** + * For a list datapoint, unfreezes the current records order and sorts it. + * For a record datapoint, unfreezes the x2many list datapoints. + * + * @param {string} elementID a valid element ID + */ + unfreezeOrder: function (elementID) { + var list = this.localData[elementID]; + if (list.type === 'record') { + var data = _.extend({}, list.data, list._changes); + for (var fieldName in data) { + var field = list.fields[fieldName]; + if (!field || !data[fieldName]) { + continue; + } + if (field.type === 'one2many' || field.type === 'many2many') { + var recordlist = this.localData[data[fieldName]]; + recordlist.orderedResIDs = null; + for (var index in recordlist.data) { + this.unfreezeOrder(recordlist.data[index]); + } + } + } + return; + } + list.orderedResIDs = null; + this._sortList(list); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add a default record to a list object. This method actually makes a new + * record with the _makeDefaultRecord method, then adds it to the list object + * as a 'ADD' command in its _changes. This is meant to be used x2many lists, + * not by list or kanban controllers. + * + * @private + * @param {Object} list a valid list object + * @param {Object} [options] + * @param {string} [options.position=top] if the new record should be added + * on top or on bottom of the list + * @param {Array} [options.[context]] additional context to be merged before + * calling the default_get (eg. to set default values). + * If several contexts are found, multiple records are added + * @param {boolean} [options.allowWarning=false] if true, the default record + * operation can complete, even if a warning is raised + * @returns {Promise<[string]>} resolves to the new records ids + */ + _addX2ManyDefaultRecord: function (list, options) { + var self = this; + var position = options && options.position || 'top'; + var params = { + fields: list.fields, + fieldsInfo: list.fieldsInfo, + parentID: list.id, + position: position, + viewType: options.viewType || list.viewType, + allowWarning: options && options.allowWarning + }; + + var additionalContexts = options && options.context; + var makeDefaultRecords = []; + if (additionalContexts){ + _.each(additionalContexts, function (context) { + params.context = self._getContext(list, {additionalContext: context}); + makeDefaultRecords.push(self._makeDefaultRecord(list.model, params)); + }); + } else { + params.context = self._getContext(list); + makeDefaultRecords.push(self._makeDefaultRecord(list.model, params)); + } + + return Promise.all(makeDefaultRecords).then(function (resultIds){ + var ids = []; + _.each(resultIds, function (id){ + ids.push(id); + + list._changes.push({operation: 'ADD', id: id, position: position, isNew: true}); + var record = self.localData[id]; + list._cache[record.res_id] = id; + if (list.orderedResIDs) { + var index = list.offset + (position !== 'top' ? list.limit : 0); + list.orderedResIDs.splice(index, 0, record.res_id); + // list could be a copy of the original one + self.localData[list.id].orderedResIDs = list.orderedResIDs; + } + }); + + return ids; + }); + }, + /** + * This method is the private version of notifyChanges. Unlike + * notifyChanges, it is not protected by a mutex. Every changes from the + * user to the model go through this method. + * + * @param {string} recordID + * @param {Object} changes + * @param {Object} [options] + * @param {boolean} [options.doNotSetDirty=false] if this flag is set to + * true, then we will not tag the record as dirty. This should be avoided + * for most situations. + * @param {boolean} [options.notifyChange=true] if this flag is set to + * false, then we will not notify and not trigger the onchange, even though + * it was changed. + * @param {string} [options.viewType] current viewType. If not set, we will assume + * main viewType from the record + * @param {boolean} [options.allowWarning=false] if true, change + * operation can complete, even if a warning is raised + * (only supported by X2ManyChange) + * @returns {Promise} list of changed fields + */ + _applyChange: function (recordID, changes, options) { + var self = this; + var record = this.localData[recordID]; + var field; + var defs = []; + options = options || {}; + record._changes = record._changes || {}; + if (!options.doNotSetDirty) { + record._isDirty = true; + } + var initialData = {}; + this._visitChildren(record, function (elem) { + initialData[elem.id] = $.extend(true, {}, _.pick(elem, 'data', '_changes')); + }); + + // apply changes to local data + for (var fieldName in changes) { + field = record.fields[fieldName]; + if (field && (field.type === 'one2many' || field.type === 'many2many')) { + defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options)); + } else if (field && (field.type === 'many2one' || field.type === 'reference')) { + defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName], options)); + } else { + record._changes[fieldName] = changes[fieldName]; + } + } + + if (options.notifyChange === false) { + return Promise.all(defs).then(function () { + return Promise.resolve(_.keys(changes)); + }); + } + + return Promise.all(defs).then(function () { + var onChangeFields = []; // the fields that have changed and that have an on_change + for (var fieldName in changes) { + field = record.fields[fieldName]; + if (field && field.onChange) { + var isX2Many = field.type === 'one2many' || field.type === 'many2many'; + if (!isX2Many || (self._isX2ManyValid(record._changes[fieldName] || record.data[fieldName]))) { + onChangeFields.push(fieldName); + } + } + } + return new Promise(function (resolve, reject) { + if (onChangeFields.length) { + self._performOnChange(record, onChangeFields, { viewType: options.viewType }) + .then(function (result) { + delete record._warning; + resolve(_.keys(changes).concat(Object.keys(result && result.value || {}))); + }).guardedCatch(function () { + self._visitChildren(record, function (elem) { + _.extend(elem, initialData[elem.id]); + }); + reject(); + }); + } else { + resolve(_.keys(changes)); + } + }).then(function (fieldNames) { + return self._fetchSpecialData(record).then(function (fieldNames2) { + // Return the names of the fields that changed (onchange or + // associated special data change) + return _.union(fieldNames, fieldNames2); + }); + }); + }); + }, + /** + * Apply an x2one (either a many2one or a reference field) change. There is + * a need for this function because the server only gives an id when a + * onchange modifies a many2one field. For this reason, we need (sometimes) + * to do a /name_get to fetch a display_name. + * + * Moreover, for the many2one case, a new value can sometimes be set (i.e. + * a display_name is given, but no id). When this happens, we first do a + * name_create. + * + * @param {Object} record + * @param {string} fieldName + * @param {Object} [data] + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {Promise} + */ + _applyX2OneChange: async function (record, fieldName, data, options) { + options = options || {}; + var self = this; + if (!data || (!data.id && !data.display_name)) { + record._changes[fieldName] = false; + return Promise.resolve(); + } + + const field = record.fields[fieldName]; + const coModel = field.type === 'reference' ? data.model : field.relation; + const allowedTypes = ['many2one', 'reference']; + if (allowedTypes.includes(field.type) && !data.id && data.display_name) { + // only display_name given -> do a name_create + const result = await this._rpc({ + model: coModel, + method: 'name_create', + args: [data.display_name], + context: this._getContext(record, {fieldName: fieldName, viewType: options.viewType}), + }); + // Check if a record is really created. Models without defined + // _rec_name cannot create record based on name_create. + if (!result) { + record._changes[fieldName] = false; + return Promise.resolve(); + } + data = {id: result[0], display_name: result[1]}; + } + + // here, we check that the many2one really changed. If the res_id is the + // same, we do not need to do any extra work. It can happen when the + // user edited a manyone (with the small form view button) with an + // onchange. In that case, the onchange is triggered, but the actual + // value did not change. + var relatedID; + if (record._changes && fieldName in record._changes) { + relatedID = record._changes[fieldName]; + } else { + relatedID = record.data[fieldName]; + } + var relatedRecord = this.localData[relatedID]; + if (relatedRecord && (data.id === this.localData[relatedID].res_id)) { + return Promise.resolve(); + } + var rel_data = _.pick(data, 'id', 'display_name'); + + // the reference field doesn't store its co-model in its field metadata + // but directly in the data (as the co-model isn't fixed) + var def; + if (rel_data.display_name === undefined) { + // TODO: refactor this to use _fetchNameGet + def = this._rpc({ + model: coModel, + method: 'name_get', + args: [data.id], + context: record.context, + }) + .then(function (result) { + rel_data.display_name = result[0][1]; + }); + } + return Promise.resolve(def).then(function () { + var rec = self._makeDataPoint({ + context: record.context, + data: rel_data, + fields: {}, + fieldsInfo: {}, + modelName: coModel, + parentID: record.id, + }); + record._changes[fieldName] = rec.id; + }); + }, + /** + * Applies the result of an onchange RPC on a record. + * + * @private + * @param {Object} values the result of the onchange RPC (a mapping of + * fieldnames to their value) + * @param {Object} record + * @param {Object} [options={}] + * @param {string} [options.viewType] current viewType. If not set, we will assume + * main viewType from the record + * @param {string} [options.firstOnChange] set to true if this is the first + * onchange (if so, some initialization will need to be done) + * @returns {Promise} + */ + _applyOnChange: function (values, record, options = {}) { + var self = this; + var defs = []; + var rec; + const viewType = options.viewType || record.viewType; + record._changes = record._changes || {}; + + for (let name in (values || {})) { + const val = values[name]; + var field = record.fields[name]; + if (!field) { + // this field is unknown so we can't process it for now (it is not + // in the current view anyway, otherwise it wouldn't be unknown. + // we store its value without processing it, so that if we later + // on switch to another view in which this field is displayed, + // we could process it as we would know its type then. + // use case: an onchange sends a create command for a one2many, + // in the dict of values, there is a value for a field that is + // not in the one2many list, but that is in the one2many form. + record._rawChanges[name] = val; + // LPE TODO 1 taskid-2261084: remove this entire comment including code snippet + // when the change in behavior has been thoroughly tested. + // It is impossible to distinguish between values returned by the default_get + // and those returned by the onchange. Since those are not in _changes, they won't be saved. + // if (options.firstOnChange) { + // record._changes[name] = val; + // } + continue; + } + if (record._rawChanges[name]) { + // if previous _rawChanges exists, clear them since the field is now knwon + // and restoring outdated onchange over posterious change is wrong + delete record._rawChanges[name]; + } + var oldValue = name in record._changes ? record._changes[name] : record.data[name]; + var id; + if (field.type === 'many2one') { + id = false; + // in some case, the value returned by the onchange can + // be false (no value), so we need to avoid creating a + // local record for that. + if (val) { + // when the value isn't false, it can be either + // an array [id, display_name] or just an id. + var data = _.isArray(val) ? + {id: val[0], display_name: val[1]} : + {id: val}; + if (!oldValue || (self.localData[oldValue].res_id !== data.id)) { + // only register a change if the value has changed + rec = self._makeDataPoint({ + context: record.context, + data: data, + modelName: field.relation, + parentID: record.id, + }); + id = rec.id; + record._changes[name] = id; + } + } else { + record._changes[name] = false; + } + } else if (field.type === 'reference') { + id = false; + if (val) { + var ref = val.split(','); + var modelName = ref[0]; + var resID = parseInt(ref[1]); + if (!oldValue || self.localData[oldValue].res_id !== resID || + self.localData[oldValue].model !== modelName) { + // only register a change if the value has changed + rec = self._makeDataPoint({ + context: record.context, + data: {id: parseInt(ref[1])}, + modelName: modelName, + parentID: record.id, + }); + defs.push(self._fetchNameGet(rec)); + id = rec.id; + record._changes[name] = id; + } + } else { + record._changes[name] = id; + } + } else if (field.type === 'one2many' || field.type === 'many2many') { + var listId = record._changes[name] || record.data[name]; + var list; + if (listId) { + list = self.localData[listId]; + } else { + var fieldInfo = record.fieldsInfo[viewType][name]; + if (!fieldInfo) { + // ignore changes of x2many not in view + continue; + } + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + list = self._makeDataPoint({ + fields: view ? view.fields : fieldInfo.relatedFields, + fieldsInfo: view ? view.fieldsInfo : fieldInfo.fieldsInfo, + limit: fieldInfo.limit, + modelName: field.relation, + parentID: record.id, + static: true, + type: 'list', + viewType: view ? view.type : fieldInfo.viewType, + }); + } + // TODO: before registering the changes, verify that the x2many + // value has changed + record._changes[name] = list.id; + list._changes = list._changes || []; + + // save it in case of a [5] which will remove the _changes + var oldChanges = list._changes; + _.each(val, function (command) { + var rec, recID; + if (command[0] === 0 || command[0] === 1) { + // CREATE or UPDATE + if (command[0] === 0 && command[1]) { + // updating an existing (virtual) record + var previousChange = _.find(oldChanges, function (operation) { + var child = self.localData[operation.id]; + return child && (child.ref === command[1]); + }); + recID = previousChange && previousChange.id; + rec = self.localData[recID]; + } + if (command[0] === 1 && command[1]) { + // updating an existing record + rec = self.localData[list._cache[command[1]]]; + } + if (!rec) { + var params = { + context: list.context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + modelName: list.model, + parentID: list.id, + viewType: list.viewType, + ref: command[1], + }; + if (command[0] === 1) { + params.res_id = command[1]; + } + rec = self._makeDataPoint(params); + list._cache[rec.res_id] = rec.id; + if (options.firstOnChange) { + // this is necessary so the fields are initialized + rec.getFieldNames().forEach(fieldName => { + if (!(fieldName in rec.data)) { + rec.data[fieldName] = null; + } + }); + } + } + // Do not abandon the record if it has been created + // from `default_get`. The list has a savepoint only + // after having fully executed `default_get`. + rec._noAbandon = !list._savePoint; + list._changes.push({operation: 'ADD', id: rec.id}); + if (command[0] === 1) { + list._changes.push({operation: 'UPDATE', id: rec.id}); + } + defs.push(self._applyOnChange(command[2], rec, { + firstOnChange: options.firstOnChange, + })); + } else if (command[0] === 4) { + // LINK TO + linkRecord(list, command[1]); + } else if (command[0] === 5) { + // DELETE ALL + list._changes = [{operation: 'REMOVE_ALL'}]; + } else if (command[0] === 6) { + list._changes = [{operation: 'REMOVE_ALL'}]; + _.each(command[2], function (resID) { + linkRecord(list, resID); + }); + } + }); + var def = self._readUngroupedList(list).then(function () { + var x2ManysDef = self._fetchX2ManysBatched(list); + var referencesDef = self._fetchReferencesBatched(list); + return Promise.all([x2ManysDef, referencesDef]); + }); + defs.push(def); + } else { + var newValue = self._parseServerValue(field, val); + if (newValue !== oldValue) { + record._changes[name] = newValue; + } + } + } + return Promise.all(defs); + + // inner function that adds a record (based on its res_id) to a list + // dataPoint (used for onchanges that return commands 4 (LINK TO) or + // commands 6 (REPLACE WITH)) + function linkRecord (list, resID) { + rec = self.localData[list._cache[resID]]; + if (rec) { + // modifications done on a record are discarded if the onchange + // uses a LINK TO or a REPLACE WITH + self.discardChanges(rec.id); + } + // the dataPoint id will be set when the record will be fetched (for + // now, this dataPoint may not exist yet) + list._changes.push({ + operation: 'ADD', + id: rec ? rec.id : null, + resID: resID, + }); + } + }, + /** + * When an operation is applied to a x2many field, the field widgets + * generate one (or more) command, which describes the exact operation. + * This method tries to interpret these commands and apply them to the + * localData. + * + * @param {Object} record + * @param {string} fieldName + * @param {Object} command A command object. It should have a 'operation' + * key. For example, it looks like {operation: ADD, id: 'partner_1'} + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will assume + * main viewType from the record + * @param {boolean} [options.allowWarning=false] if true, change + * operation can complete, even if a warning is raised + * (only supported by the 'CREATE' command.operation) + * @returns {Promise} + */ + _applyX2ManyChange: async function (record, fieldName, command, options) { + if (command.operation === 'TRIGGER_ONCHANGE') { + // the purpose of this operation is to trigger an onchange RPC, so + // there is no need to apply any change on the record (the changes + // have probably been already applied and saved, usecase: many2many + // edition in a dialog) + return Promise.resolve(); + } + + var self = this; + var localID = (record._changes && record._changes[fieldName]) || record.data[fieldName]; + var list = this.localData[localID]; + var field = record.fields[fieldName]; + var viewType = (options && options.viewType) || record.viewType; + var fieldInfo = record.fieldsInfo[viewType][fieldName]; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var def, rec; + var defs = []; + list._changes = list._changes || []; + + switch (command.operation) { + case 'ADD': + // for now, we are in the context of a one2many field + // the command should look like this: + // { operation: 'ADD', id: localID } + // The corresponding record may contain value for fields that + // are unknown in the list (e.g. fields that are in the + // subrecord form view but not in the kanban or list view), so + // to ensure that onchanges are correctly handled, we extend the + // list's fields with those in the created record + var newRecord = this.localData[command.id]; + _.defaults(list.fields, newRecord.fields); + _.defaults(list.fieldsInfo, newRecord.fieldsInfo); + newRecord.fields = list.fields; + newRecord.fieldsInfo = list.fieldsInfo; + newRecord.viewType = list.viewType; + list._cache[newRecord.res_id] = newRecord.id; + list._changes.push(command); + break; + case 'ADD_M2M': + // force to use link command instead of create command + list._forceM2MLink = true; + // handle multiple add: command[2] may be a dict of values (1 + // record added) or an array of dict of values + var data = _.isArray(command.ids) ? command.ids : [command.ids]; + + // name_create records for which there is no id (typically, could + // be the case of a quick_create in a many2many_tags, so data.length + // is 1) + for (const r of data) { + if (!r.id && r.display_name) { + const prom = this._rpc({ + model: field.relation, + method: 'name_create', + args: [r.display_name], + context: this._getContext(record, {fieldName: fieldName, viewType: options.viewType}), + }).then(result => { + r.id = result[0]; + r.display_name = result[1]; + }); + defs.push(prom); + } + } + await Promise.all(defs); + + // Ensure the local data repository (list) boundaries can handle incoming records (data) + if (data.length + list.res_ids.length > list.limit) { + list.limit = data.length + list.res_ids.length; + } + + var list_records = {}; + _.each(data, function (d) { + rec = self._makeDataPoint({ + context: record.context, + modelName: field.relation, + fields: view ? view.fields : fieldInfo.relatedFields, + fieldsInfo: view ? view.fieldsInfo : fieldInfo.fieldsInfo, + res_id: d.id, + data: d, + viewType: view ? view.type : fieldInfo.viewType, + parentID: list.id, + }); + list_records[d.id] = rec; + list._cache[rec.res_id] = rec.id; + list._changes.push({operation: 'ADD', id: rec.id}); + }); + // read list's records as we only have their ids and optionally their display_name + // (we can't use function readUngroupedList because those records are only in the + // _changes so this is a very specific case) + // this could be optimized by registering the fetched records in the list's _cache + // so that if a record is removed and then re-added, it won't be fetched twice + var fieldNames = list.getFieldNames(); + if (fieldNames.length) { + def = this._rpc({ + model: list.model, + method: 'read', + args: [_.pluck(data, 'id'), fieldNames], + context: _.extend({}, record.context, field.context), + }).then(function (records) { + _.each(records, function (record) { + list_records[record.id].data = record; + self._parseServerData(fieldNames, list, record); + }); + return Promise.all([ + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ]); + }); + defs.push(def); + } + break; + case 'CREATE': + var createOptions = _.extend({ + context: command.context, + position: command.position + }, options || {}); + createOptions.viewType = fieldInfo.mode; + + def = this._addX2ManyDefaultRecord(list, createOptions).then(function (ids) { + _.each(ids, function(id){ + if (command.position === 'bottom' && list.orderedResIDs && list.orderedResIDs.length >= list.limit) { + list.tempLimitIncrement = (list.tempLimitIncrement || 0) + 1; + list.limit += 1; + } + // FIXME: hack for lunch widget, which does useless default_get and onchange + if (command.data) { + return self._applyChange(id, command.data); + } + }); + }); + defs.push(def); + break; + case 'UPDATE': + list._changes.push({operation: 'UPDATE', id: command.id}); + if (command.data) { + defs.push(this._applyChange(command.id, command.data, { + viewType: view && view.type, + })); + } + break; + case 'FORGET': + // Unlink the record of list. + list._forceM2MUnlink = true; + case 'DELETE': + // filter out existing operations involving the current + // dataPoint, and add a 'DELETE' or 'FORGET' operation only if there is + // no 'ADD' operation for that dataPoint, as it would mean + // that the record wasn't in the relation yet + var idsToRemove = command.ids; + list._changes = _.reject(list._changes, function (change, index) { + var idInCommands = _.contains(command.ids, change.id); + if (idInCommands && change.operation === 'ADD') { + idsToRemove = _.without(idsToRemove, change.id); + } + return idInCommands; + }); + _.each(idsToRemove, function (id) { + var operation = list._forceM2MUnlink ? 'FORGET': 'DELETE'; + list._changes.push({operation: operation, id: id}); + }); + break; + case 'DELETE_ALL': + // first remove all pending 'ADD' operations + list._changes = _.reject(list._changes, function (change) { + return change.operation === 'ADD'; + }); + + // then apply 'DELETE' on existing records + return this._applyX2ManyChange(record, fieldName, { + operation: 'DELETE', + ids: list.res_ids + }, options); + case 'REPLACE_WITH': + // this is certainly not optimal... and not sure that it is + // correct if some ids are added and some other are removed + list._changes = []; + var newIds = _.difference(command.ids, list.res_ids); + var removedIds = _.difference(list.res_ids, command.ids); + var addDef, removedDef, values; + if (newIds.length) { + values = _.map(newIds, function (id) { + return {id: id}; + }); + addDef = this._applyX2ManyChange(record, fieldName, { + operation: 'ADD_M2M', + ids: values + }, options); + } + if (removedIds.length) { + var listData = _.map(list.data, function (localId) { + return self.localData[localId]; + }); + removedDef = this._applyX2ManyChange(record, fieldName, { + operation: 'DELETE', + ids: _.map(removedIds, function (resID) { + if (resID in list._cache) { + return list._cache[resID]; + } + return _.findWhere(listData, {res_id: resID}).id; + }), + }, options); + } + return Promise.all([addDef, removedDef]); + case 'MULTI': + // allows batching multiple operations + _.each(command.commands, function (innerCommand){ + defs.push(self._applyX2ManyChange( + record, + fieldName, + innerCommand, + options + )); + }); + break; + } + + return Promise.all(defs).then(function () { + // ensure to fetch up to 'limit' records (may be useful if records of + // the current page have been removed) + return self._readUngroupedList(list).then(function () { + return self._fetchX2ManysBatched(list); + }); + }); + }, + /** + * In dataPoints of type list for x2manys, the changes are stored as a list + * of operations (being of type 'ADD', 'DELETE', 'FORGET', UPDATE' or 'REMOVE_ALL'). + * This function applies the operation of such a dataPoint without altering + * the original dataPoint. It returns a copy of the dataPoint in which the + * 'count', 'data' and 'res_ids' keys have been updated. + * + * @private + * @param {Object} dataPoint of type list + * @param {Object} [options] mostly contains the range of operations to apply + * @param {Object} [options.from=0] the index of the first operation to apply + * @param {Object} [options.to=length] the index of the last operation to apply + * @param {Object} [options.position] if set, each new operation will be set + * accordingly at the top or the bottom of the list + * @returns {Object} element of type list in which the commands have been + * applied + */ + _applyX2ManyOperations: function (list, options) { + if (!list.static) { + // this function only applies on x2many lists + return list; + } + var self = this; + list = _.extend({}, list); + list.res_ids = list.res_ids.slice(0); + var changes = list._changes || []; + if (options) { + var to = options.to === 0 ? 0 : (options.to || changes.length); + changes = changes.slice(options.from || 0, to); + } + _.each(changes, function (change) { + var relRecord; + if (change.id) { + relRecord = self.localData[change.id]; + } + switch (change.operation) { + case 'ADD': + list.count++; + var resID = relRecord ? relRecord.res_id : change.resID; + if (change.position === 'top' && (options ? options.position !== 'bottom' : true)) { + list.res_ids.unshift(resID); + } else { + list.res_ids.push(resID); + } + break; + case 'FORGET': + case 'DELETE': + list.count--; + // FIXME awa: there is no "relRecord" for o2m field + // seems like using change.id does the trick -> check with framework JS + var deletedResID = relRecord ? relRecord.res_id : change.id; + list.res_ids = _.without(list.res_ids, deletedResID); + break; + case 'REMOVE_ALL': + list.count = 0; + list.res_ids = []; + break; + case 'UPDATE': + // nothing to do for UPDATE commands + break; + } + }); + this._setDataInRange(list); + return list; + }, + /** + * Helper method to build a 'spec', that is a description of all fields in + * the view that have a onchange defined on them. + * + * An onchange spec is necessary as an argument to the /onchange route. It + * looks like this: { field: "1", anotherField: "", relation.subField: "1"} + * + * The first onchange call will fill up the record with default values, so + * we need to send every field name known to us in this case. + * + * @see _performOnChange + * + * @param {Object} record resource object of type 'record' + * @param {string} [viewType] current viewType. If not set, we will assume + * main viewType from the record + * @returns {Object} with two keys + * - 'hasOnchange': true iff there is at least a field with onchange + * - 'onchangeSpec': the onchange spec + */ + _buildOnchangeSpecs: function (record, viewType) { + let hasOnchange = false; + const onchangeSpec = {}; + var fieldsInfo = record.fieldsInfo[viewType || record.viewType]; + generateSpecs(fieldsInfo, record.fields); + + // recursively generates the onchange specs for fields in fieldsInfo, + // and their subviews + function generateSpecs(fieldsInfo, fields, prefix) { + prefix = prefix || ''; + _.each(Object.keys(fieldsInfo), function (name) { + var field = fields[name]; + var fieldInfo = fieldsInfo[name]; + var key = prefix + name; + onchangeSpec[key] = (field.onChange) || ""; + if (field.onChange) { + hasOnchange = true; + } + if (field.type === 'one2many' || field.type === 'many2many') { + _.each(fieldInfo.views, function (view) { + generateSpecs(view.fieldsInfo[view.type], view.fields, key + '.'); + }); + } + }); + } + return { hasOnchange, onchangeSpec }; + }, + /** + * Ensures that dataPoint ids are always synchronized between the main and + * sample models when being in sample mode. Here, we now that __id in the + * sample model is always greater than __id in the main model (as it + * contains strictly more datapoints). + * + * @override + */ + async _callSampleModel() { + await this._super(...arguments); + if (this._isInSampleMode) { + this.__id = this.sampleModel.__id; + } + }, + /** + * Compute the default value that the handle field should take. + * We need to compute this in order for new lines to be added at the correct position. + * + * @private + * @param {Object} listID + * @param {string} position + * @return {Object} empty object if no overrie has to be done, or: + * field: the name of the field to override, + * value: the value to use for that field + */ + _computeOverrideDefaultFields: function (listID, position) { + var list = this.localData[listID]; + var handleField; + + // Here listID is actually just parentID, it's not yet confirmed + // to be a list. + // If we are not in the case that interests us, + // listID will be undefined and this check will work. + if (!list) { + return {}; + } + + position = position || 'bottom'; + + // Let's find if there is a field with handle. + if (!list.fieldsInfo) { + return {}; + } + for (var field in list.fieldsInfo.list) { + if (list.fieldsInfo.list[field].widget === 'handle') { + handleField = field; + break; + // If there are 2 handle fields on the same list, + // we take the first one we find. + // And that will be alphabetically on the field name... + } + } + + if (!handleField) { + return {}; + } + + // We don't want to override the default value + // if the list is not ordered by the handle field. + var isOrderedByHandle = list.orderedBy + && list.orderedBy.length + && list.orderedBy[0].asc === true + && list.orderedBy[0].name === handleField; + + if (!isOrderedByHandle) { + return {}; + } + + // We compute the list (get) to apply the pending changes before doing our work, + // otherwise new lines might not be taken into account. + // We use raw: true because we only need to load the first level of relation. + var computedList = this.get(list.id, {raw: true}); + + // We don't need to worry about the position of a new line if the list is empty. + if (!computedList || !computedList.data || !computedList.data.length) { + return {}; + } + + // If there are less elements in the list than the limit of + // the page then take the index of the last existing line. + + // If the button is at the top, we want the new element on + // the first line of the page. + + // If the button is at the bottom, we want the new element + // after the last line of the page + // (= theorically it will be the first element of the next page). + + // We ignore list.offset because computedList.data + // will only have the current page elements. + + var index = Math.min( + computedList.data.length - 1, + position !== 'top' ? list.limit - 1 : 0 + ); + + // This positioning will almost be correct. There might just be + // an issue if several other lines have the same handleFieldValue. + + // TODO ideally: if there is an element with the same handleFieldValue, + // that one and all the following elements must be incremented + // by 1 (at least until there is a gap in the numbering). + + // We don't do it now because it's not an important case. + // However, we can for sure increment by 1 if we are on the last page. + var handleFieldValue = computedList.data[index].data[handleField]; + if (position === 'top') { + handleFieldValue--; + } else if (list.count <= list.offset + list.limit - (list.tempLimitIncrement || 0)) { + handleFieldValue++; + } + return { + field: handleField, + value: handleFieldValue, + }; + }, + /** + * Evaluate modifiers + * + * @private + * @param {Object} element a valid element object, which will serve as eval + * context. + * @param {Object} modifiers + * @returns {Object} + * @throws {Error} if one of the modifier domains is invalid + */ + _evalModifiers: function (element, modifiers) { + let evalContext = null; + const evaluated = {}; + for (const k of ['invisible', 'column_invisible', 'readonly', 'required']) { + const mod = modifiers[k]; + if (mod === undefined || mod === false || mod === true) { + if (k in modifiers) { + evaluated[k] = !!mod; + } + continue; + } + try { + evalContext = evalContext || this._getEvalContext(element); + evaluated[k] = new Domain(mod, evalContext).compute(evalContext); + } catch (e) { + throw new Error(_.str.sprintf('for modifier "%s": %s', k, e.message)); + } + } + return evaluated; + }, + /** + * Fetch name_get for a record datapoint. + * + * @param {Object} dataPoint + * @returns {Promise} + */ + _fetchNameGet: function (dataPoint) { + return this._rpc({ + model: dataPoint.model, + method: 'name_get', + args: [dataPoint.res_id], + context: dataPoint.getContext(), + }).then(function (result) { + dataPoint.data.display_name = result[0][1]; + }); + }, + /** + * Fetch name_get for a field of type Many2one or Reference + * + * @private + * @params {Object} list: must be a datapoint of type list + * (for example: a datapoint representing a x2many) + * @params {string} fieldName: the name of a field of type Many2one or Reference + * @returns {Promise} + */ + _fetchNameGets: function (list, fieldName) { + var self = this; + // We first get the model this way because if list.data is empty + // the _.each below will not make it. + var model = list.fields[fieldName].relation; + var records = []; + var ids = []; + list = this._applyX2ManyOperations(list); + + _.each(list.data, function (localId) { + var record = self.localData[localId]; + var data = record._changes || record.data; + var many2oneId = data[fieldName]; + if (!many2oneId) { return; } + var many2oneRecord = self.localData[many2oneId]; + records.push(many2oneRecord); + ids.push(many2oneRecord.res_id); + // We need to calculate the model this way too because + // field .relation is not set for a reference field. + model = many2oneRecord.model; + }); + + if (!ids.length) { + return Promise.resolve(); + } + return this._rpc({ + model: model, + method: 'name_get', + args: [_.uniq(ids)], + context: list.context, + }) + .then(function (name_gets) { + _.each(records, function (record) { + var nameGet = _.find(name_gets, function (nameGet) { + return nameGet[0] === record.data.id; + }); + record.data.display_name = nameGet[1]; + }); + }); + }, + /** + * For a given resource of type 'record', fetch all data. + * + * @param {Object} record local resource + * @param {Object} [options] + * @param {string[]} [options.fieldNames] the list of fields to fetch. If + * not given, fetch all the fields in record.fieldNames (+ display_name) + * @param {string} [options.viewType] the type of view for which the record + * is fetched (usefull to load the adequate fields), by defaults, uses + * record.viewType + * @returns {Promise<Object>} resolves to the record or is rejected in + * case no id given were valid ids + */ + _fetchRecord: function (record, options) { + var self = this; + options = options || {}; + var fieldNames = options.fieldNames || record.getFieldNames(options); + fieldNames = _.uniq(fieldNames.concat(['display_name'])); + return this._rpc({ + model: record.model, + method: 'read', + args: [[record.res_id], fieldNames], + context: _.extend({bin_size: true}, record.getContext()), + }) + .then(function (result) { + if (result.length === 0) { + return Promise.reject(); + } + result = result[0]; + record.data = _.extend({}, record.data, result); + }) + .then(function () { + self._parseServerData(fieldNames, record, record.data); + }) + .then(function () { + return Promise.all([ + self._fetchX2Manys(record, options), + self._fetchReferences(record, options) + ]).then(function () { + return self._postprocess(record, options); + }); + }); + }, + /** + * Fetch the `name_get` for a reference field. + * + * @private + * @param {Object} record + * @param {string} fieldName + * @returns {Promise} + */ + _fetchReference: function (record, fieldName) { + var self = this; + var def; + var value = record._changes && record._changes[fieldName] || record.data[fieldName]; + var model = value && value.split(',')[0]; + var resID = value && parseInt(value.split(',')[1]); + if (model && model !== 'False' && resID) { + def = self._rpc({ + model: model, + method: 'name_get', + args: [resID], + context: record.getContext({fieldName: fieldName}), + }).then(function (result) { + return self._makeDataPoint({ + data: { + id: result[0][0], + display_name: result[0][1], + }, + modelName: model, + parentID: record.id, + }); + }); + } + return Promise.resolve(def); + }, + /** + * Fetches data for reference fields and assigns these data to newly + * created datapoint. + * Then places datapoint reference into parent record. + * + * @param {Object} datapoints a collection of ids classed by model, + * @see _getDataToFetchByModel + * @param {string} model + * @param {string} fieldName + * @returns {Promise} + */ + _fetchReferenceData: function (datapoints, model, fieldName) { + var self = this; + var ids = _.map(Object.keys(datapoints), function (id) { return parseInt(id); }); + // we need one parent for the context (they all have the same) + var parent = datapoints[ids[0]][0]; + var def = self._rpc({ + model: model, + method: 'name_get', + args: [ids], + context: self.localData[parent].getContext({fieldName: fieldName}), + }).then(function (result) { + _.each(result, function (el) { + var parentIDs = datapoints[el[0]]; + _.each(parentIDs, function (parentID) { + var parent = self.localData[parentID]; + var referenceDp = self._makeDataPoint({ + data: { + id: el[0], + display_name: el[1], + }, + modelName: model, + parentID: parent.id, + }); + parent.data[fieldName] = referenceDp.id; + }); + }); + }); + return def; + }, + /** + * Fetch the extra data (`name_get`) for the reference fields of the record + * model. + * + * @private + * @param {Object} record + * @returns {Promise} + */ + _fetchReferences: function (record, options) { + var self = this; + var defs = []; + var fieldNames = options && options.fieldNames || record.getFieldNames(); + _.each(fieldNames, function (fieldName) { + var field = record.fields[fieldName]; + if (field.type === 'reference') { + var def = self._fetchReference(record, fieldName).then(function (dataPoint) { + if (dataPoint) { + record.data[fieldName] = dataPoint.id; + } + }); + defs.push(def); + } + }); + return Promise.all(defs); + }, + /** + * Batch requests for one reference field in list (one request by different + * model in the field values). + * + * @see _fetchReferencesBatched + * @param {Object} list + * @param {string} fieldName + * @returns {Promise} + */ + _fetchReferenceBatched: function (list, fieldName) { + var self = this; + list = this._applyX2ManyOperations(list); + this._sortList(list); + + var toFetch = this._getDataToFetchByModel(list, fieldName); + var defs = []; + // one name_get by model + _.each(toFetch, function (datapoints, model) { + defs.push(self._fetchReferenceData(datapoints, model, fieldName)); + }); + + return Promise.all(defs); + }, + /** + * Batch requests for references for datapoint of type list. + * + * @param {Object} list + * @returns {Promise} + */ + _fetchReferencesBatched: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var field = list.fields[fieldNames[i]]; + if (field.type === 'reference') { + defs.push(this._fetchReferenceBatched(list, fieldNames[i])); + } + } + return Promise.all(defs); + }, + /** + * Batch reference requests for all records in list. + * + * @see _fetchReferencesSingleBatch + * @param {Object} list a valid resource object + * @param {string} fieldName + * @returns {Promise} + */ + _fetchReferenceSingleBatch: function (list, fieldName) { + var self = this; + + // collect ids by model + var toFetch = {}; + _.each(list.data, function (groupIndex) { + var group = self.localData[groupIndex]; + self._getDataToFetchByModel(group, fieldName, toFetch); + }); + + var defs = []; + // one name_get by model + _.each(toFetch, function (datapoints, model) { + defs.push(self._fetchReferenceData(datapoints, model, fieldName)); + }); + + return Promise.all(defs); + }, + /** + * Batch requests for all reference field in list's children. + * Called by _readGroup to make only one 'name_get' rpc by fieldName. + * + * @param {Object} list a valid resource object + * @returns {Promise} + */ + _fetchReferencesSingleBatch: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var fIndex in fieldNames) { + var field = list.fields[fieldNames[fIndex]]; + if (field.type === 'reference') { + defs.push(this._fetchReferenceSingleBatch(list, fieldNames[fIndex])); + } + } + return Promise.all(defs); + }, + /** + * Fetch model data from server, relationally to fieldName and resulted + * field relation. For example, if fieldName is "tag_ids" and referred to + * project.tags, it will fetch project.tags' related fields where its id is + * contained in toFetch.ids array. + * + * @param {Object} list a valid resource object + * @param {Object} toFetch a list of records and res_ids, + * @see _getDataToFetch + * @param {string} fieldName + * @returns {Promise} + */ + _fetchRelatedData: function (list, toFetch, fieldName) { + var self = this; + var ids = _.keys(toFetch); + for (var i = 0; i < ids.length; i++) { + ids[i] = Number(ids[i]); + } + var fieldInfo = list.fieldsInfo[list.viewType][fieldName]; + + if (!ids.length || fieldInfo.__no_fetch) { + return Promise.resolve(); + } + + var def; + var fieldNames = _.keys(fieldInfo.relatedFields); + if (fieldNames.length) { + var field = list.fields[fieldName]; + def = this._rpc({ + model: field.relation, + method: 'read', + args: [ids, fieldNames], + context: list.getContext() || {}, + }); + } else { + def = Promise.resolve(_.map(ids, function (id) { + return {id:id}; + })); + } + return def.then(function (result) { + var records = _.uniq(_.flatten(_.values(toFetch))); + self._updateRecordsData(records, fieldName, result); + }); + }, + /** + * Check the AbstractField specializations that are (will be) used by the + * given record and fetch the special data they will need. Special data are + * data that the rendering of the record won't need if it was not using + * particular widgets (example of these can be found at the methods which + * start with _fetchSpecial). + * + * @param {Object} record - an element from the localData + * @param {Object} options + * @returns {Promise<Array>} + * The promise is resolved with an array containing the names of + * the field whose special data has been changed. + */ + _fetchSpecialData: function (record, options) { + var self = this; + var specialFieldNames = []; + var fieldNames = (options && options.fieldNames) || record.getFieldNames(); + return Promise.all(_.map(fieldNames, function (name) { + var viewType = (options && options.viewType) || record.viewType; + var fieldInfo = record.fieldsInfo[viewType][name] || {}; + var Widget = fieldInfo.Widget; + if (Widget && Widget.prototype.specialData) { + return self[Widget.prototype.specialData](record, name, fieldInfo).then(function (data) { + if (data === undefined) { + return; + } + record.specialData[name] = data; + specialFieldNames.push(name); + }); + } + })).then(function () { + return specialFieldNames; + }); + }, + /** + * Fetches all the m2o records associated to the given fieldName. If the + * given fieldName is not a m2o field, nothing is done. + * + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @param {Object} fieldInfo + * @param {string[]} [fieldsToRead] - the m2os fields to read (id and + * display_name are automatic). + * @returns {Promise<any>} + * The promise is resolved with the fetched special data. If this + * data is the same as the previously fetched one (for the given + * parameters), no RPC is done and the promise is resolved with + * the undefined value. + */ + _fetchSpecialMany2ones: function (record, fieldName, fieldInfo, fieldsToRead) { + var field = record.fields[fieldName]; + if (field.type !== "many2one") { + return Promise.resolve(); + } + + var context = record.getContext({fieldName: fieldName}); + var domain = record.getDomain({fieldName: fieldName}); + if (domain.length) { + var localID = (record._changes && fieldName in record._changes) ? + record._changes[fieldName] : + record.data[fieldName]; + if (localID) { + var element = this.localData[localID]; + domain = ["|", ["id", "=", element.data.id]].concat(domain); + } + } + + // avoid rpc if not necessary + var hasChanged = this._saveSpecialDataCache(record, fieldName, { + context: context, + domain: domain, + }); + if (!hasChanged) { + return Promise.resolve(); + } + + var self = this; + return this._rpc({ + model: field.relation, + method: 'search_read', + fields: ["id"].concat(fieldsToRead || []), + context: context, + domain: domain, + }) + .then(function (records) { + var ids = _.pluck(records, 'id'); + return self._rpc({ + model: field.relation, + method: 'name_get', + args: [ids], + context: context, + }) + .then(function (name_gets) { + _.each(records, function (rec) { + var name_get = _.find(name_gets, function (n) { + return n[0] === rec.id; + }); + rec.display_name = name_get[1]; + }); + return records; + }); + }); + }, + /** + * Fetches all the relation records associated to the given fieldName. If + * the given fieldName is not a relational field, nothing is done. + * + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @returns {Promise<any>} + * The promise is resolved with the fetched special data. If this + * data is the same as the previously fetched one (for the given + * parameters), no RPC is done and the promise is resolved with + * the undefined value. + */ + _fetchSpecialRelation: function (record, fieldName) { + var field = record.fields[fieldName]; + if (!_.contains(["many2one", "many2many", "one2many"], field.type)) { + return Promise.resolve(); + } + + var context = record.getContext({fieldName: fieldName}); + var domain = record.getDomain({fieldName: fieldName}); + + // avoid rpc if not necessary + var hasChanged = this._saveSpecialDataCache(record, fieldName, { + context: context, + domain: domain, + }); + if (!hasChanged) { + return Promise.resolve(); + } + + return this._rpc({ + model: field.relation, + method: 'name_search', + args: ["", domain], + context: context + }); + }, + /** + * Fetches the `name_get` associated to the reference widget if the field is + * a `char` (which is a supported case). + * + * @private + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @returns {Promise} + */ + _fetchSpecialReference: function (record, fieldName) { + var def; + var field = record.fields[fieldName]; + if (field.type === 'char') { + // if the widget reference is set on a char field, the name_get + // needs to be fetched a posteriori + def = this._fetchReference(record, fieldName); + } + return Promise.resolve(def); + }, + /** + * Fetches all the m2o records associated to the given fieldName. If the + * given fieldName is not a m2o field, nothing is done. The difference with + * _fetchSpecialMany2ones is that the field given by options.fold_field is + * also fetched. + * + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @param {Object} fieldInfo + * @returns {Promise<any>} + * The promise is resolved with the fetched special data. If this + * data is the same as the previously fetched one (for the given + * parameters), no RPC is done and the promise is resolved with + * the undefined value. + */ + _fetchSpecialStatus: function (record, fieldName, fieldInfo) { + var foldField = fieldInfo.options.fold_field; + var fieldsToRead = foldField ? [foldField] : []; + return this._fetchSpecialMany2ones(record, fieldName, fieldInfo, fieldsToRead).then(function (m2os) { + _.each(m2os, function (m2o) { + m2o.fold = foldField ? m2o[foldField] : false; + }); + return m2os; + }); + }, + /** + * Fetches the number of records associated to the domain the value of the + * given field represents. + * + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @param {Object} fieldInfo + * @returns {Promise<any>} + * The promise is resolved with the fetched special data. If this + * data is the same as the previously fetched one (for the given + * parameters), no RPC is done and the promise is resolved with + * the undefined value. + */ + _fetchSpecialDomain: function (record, fieldName, fieldInfo) { + var self = this; + var context = record.getContext({fieldName: fieldName}); + + var domainModel = fieldInfo.options.model; + if (record.data.hasOwnProperty(domainModel)) { + domainModel = record._changes && record._changes[domainModel] || record.data[domainModel]; + } + var domainValue = record._changes && record._changes[fieldName] || record.data[fieldName] || []; + + // avoid rpc if not necessary + var hasChanged = this._saveSpecialDataCache(record, fieldName, { + context: context, + domainModel: domainModel, + domainValue: domainValue, + }); + if (!hasChanged) { + return Promise.resolve(); + } else if (!domainModel) { + return Promise.resolve({ + model: domainModel, + nbRecords: 0, + }); + } + + return new Promise(function (resolve) { + var evalContext = self._getEvalContext(record); + self._rpc({ + model: domainModel, + method: 'search_count', + args: [Domain.prototype.stringToArray(domainValue, evalContext)], + context: context + }) + .then(function (nbRecords) { + resolve({ + model: domainModel, + nbRecords: nbRecords, + }); + }) + .guardedCatch(function (reason) { + var e = reason.event; + e.preventDefault(); // prevent traceback (the search_count might be intended to break) + resolve({ + model: domainModel, + nbRecords: 0, + }); + }); + }); + }, + /** + * Fetch all data in a ungrouped list + * + * @param {Object} list a valid resource object + * @param {Object} [options] + * @param {boolean} [options.enableRelationalFetch=true] if false, will not + * fetch x2m and relational data (that will be done by _readGroup in this + * case). + * @returns {Promise<Object>} resolves to the fecthed list + */ + _fetchUngroupedList: function (list, options) { + options = _.defaults(options || {}, {enableRelationalFetch: true}); + var self = this; + var def; + if (list.static) { + def = this._readUngroupedList(list).then(function () { + if (list.parentID && self.isNew(list.parentID)) { + // list from a default_get, so fetch display_name for many2one fields + var many2ones = self._getMany2OneFieldNames(list); + var defs = _.map(many2ones, function (name) { + return self._fetchNameGets(list, name); + }); + return Promise.all(defs); + } + }); + } else { + def = this._searchReadUngroupedList(list); + } + return def.then(function () { + if (options.enableRelationalFetch) { + return Promise.all([ + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ]); + } + }).then(function () { + return list; + }); + }, + /** + * batch requests for 1 x2m in list + * + * @see _fetchX2ManysBatched + * @param {Object} list + * @param {string} fieldName + * @returns {Promise} + */ + _fetchX2ManyBatched: function (list, fieldName) { + list = this._applyX2ManyOperations(list); + this._sortList(list); + + var toFetch = this._getDataToFetch(list, fieldName); + return this._fetchRelatedData(list, toFetch, fieldName); + }, + /** + * X2Manys have to be fetched by separate rpcs (their data are stored on + * different models). This method takes a record, look at its x2many fields, + * then, if necessary, create a local resource and fetch the corresponding + * data. + * + * It also tries to reuse data, if it can find an existing list, to prevent + * useless rpcs. + * + * @param {Object} record local resource + * @param {Object} [options] + * @param {string[]} [options.fieldNames] the list of fields to fetch. + * If not given, fetch all the fields in record.fieldNames + * @param {string} [options.viewType] the type of view for which the main + * record is fetched (useful to load the adequate fields), by defaults, + * uses record.viewType + * @returns {Promise} + */ + _fetchX2Manys: function (record, options) { + var self = this; + var defs = []; + options = options || {}; + var fieldNames = options.fieldNames || record.getFieldNames(options); + var viewType = options.viewType || record.viewType; + _.each(fieldNames, function (fieldName) { + var field = record.fields[fieldName]; + if (field.type === 'one2many' || field.type === 'many2many') { + var fieldInfo = record.fieldsInfo[viewType][fieldName]; + var rawContext = fieldInfo && fieldInfo.context; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : (fieldInfo.fieldsInfo || {}); + var ids = record.data[fieldName] || []; + var list = self._makeDataPoint({ + count: ids.length, + context: _.extend({}, record.context, field.context), + fieldsInfo: fieldsInfo, + fields: view ? view.fields : fieldInfo.relatedFields, + limit: fieldInfo.limit, + modelName: field.relation, + res_ids: ids, + static: true, + type: 'list', + orderedBy: fieldInfo.orderedBy, + parentID: record.id, + rawContext: rawContext, + relationField: field.relation_field, + viewType: view ? view.type : fieldInfo.viewType, + }); + record.data[fieldName] = list.id; + if (!fieldInfo.__no_fetch) { + var def = self._readUngroupedList(list).then(function () { + return Promise.all([ + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ]); + }); + defs.push(def); + } + } + }); + return Promise.all(defs); + }, + /** + * batch request for x2ms for datapoint of type list + * + * @param {Object} list + * @returns {Promise} + */ + _fetchX2ManysBatched: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var field = list.fields[fieldNames[i]]; + if (field.type === 'many2many' || field.type === 'one2many') { + defs.push(this._fetchX2ManyBatched(list, fieldNames[i])); + } + } + return Promise.all(defs); + }, + /** + * For a non-static list, batches requests for all its sublists' records. + * Make only one rpc for all records on the concerned field. + * + * @see _fetchX2ManysSingleBatch + * @param {Object} list a valid resource object, its data must be another + * list containing records + * @param {string} fieldName + * @returns {Promise} + */ + _fetchX2ManySingleBatch: function (list, fieldName) { + var self = this; + var toFetch = {}; + _.each(list.data, function (groupIndex) { + var group = self.localData[groupIndex]; + var nextDataToFetch = self._getDataToFetch(group, fieldName); + _.each(_.keys(nextDataToFetch), function (id) { + if (toFetch[id]) { + toFetch[id] = toFetch[id].concat(nextDataToFetch[id]); + } else { + toFetch[id] = nextDataToFetch[id]; + } + }); + }); + return self._fetchRelatedData(list, toFetch, fieldName); + }, + /** + * Batch requests for all x2m in list's children. + * Called by _readGroup to make only one 'read' rpc by fieldName. + * + * @param {Object} list a valid resource object + * @returns {Promise} + */ + _fetchX2ManysSingleBatch: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var field = list.fields[fieldNames[i]]; + if (field.type === 'many2many' || field.type === 'one2many'){ + defs.push(this._fetchX2ManySingleBatch(list, fieldNames[i])); + } + } + return Promise.all(defs); + }, + /** + * Generates an object mapping field names to their changed value in a given + * record (i.e. maps to the new value for basic fields, to the res_id for + * many2ones and to commands for x2manys). + * + * @private + * @param {Object} record + * @param {Object} [options] + * @param {boolean} [options.changesOnly=true] if true, only generates + * commands for fields that have changed (concerns x2many fields only) + * @param {boolean} [options.withReadonly=false] if false, doesn't generate + * changes for readonly fields + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record. Note that if an editionViewType is + * specified for a field, it will take the priority over the viewType arg. + * @returns {Object} a map from changed fields to their new value + */ + _generateChanges: function (record, options) { + options = options || {}; + var viewType = options.viewType || record.viewType; + var changes; + const changesOnly = 'changesOnly' in options ? !!options.changesOnly : true; + if (!changesOnly) { + changes = _.extend({}, record.data, record._changes); + } else { + changes = _.extend({}, record._changes); + } + var withReadonly = options.withReadonly || false; + var commands = this._generateX2ManyCommands(record, { + changesOnly: changesOnly, + withReadonly: withReadonly, + }); + for (var fieldName in record.fields) { + // remove readonly fields from the list of changes + if (!withReadonly && fieldName in changes || fieldName in commands) { + var editionViewType = record._editionViewType[fieldName] || viewType; + if (this._isFieldProtected(record, fieldName, editionViewType)) { + delete changes[fieldName]; + continue; + } + } + + // process relational fields and handle the null case + var type = record.fields[fieldName].type; + var value; + if (type === 'one2many' || type === 'many2many') { + if (!changesOnly || (commands[fieldName] && commands[fieldName].length)) { // replace localId by commands + changes[fieldName] = commands[fieldName]; + } else { // no command -> no change for that field + delete changes[fieldName]; + } + } else if (type === 'many2one' && fieldName in changes) { + value = changes[fieldName]; + changes[fieldName] = value ? this.localData[value].res_id : false; + } else if (type === 'reference' && fieldName in changes) { + value = changes[fieldName]; + changes[fieldName] = value ? + this.localData[value].model + ',' + this.localData[value].res_id : + false; + } else if (type === 'char' && changes[fieldName] === '') { + changes[fieldName] = false; + } else if (changes[fieldName] === null) { + changes[fieldName] = false; + } + } + return changes; + }, + /** + * Generates an object mapping field names to their current value in a given + * record. If the record is inside a one2many, the returned object contains + * an additional key (the corresponding many2one field name) mapping to the + * current value of the parent record. + * + * @param {Object} record + * @param {Object} [options] This option object will be given to the private + * method _generateX2ManyCommands. In particular, it is useful to be able + * to send changesOnly:true to get all data, not only the current changes. + * @returns {Object} the data + */ + _generateOnChangeData: function (record, options) { + options = _.extend({}, options || {}, {withReadonly: true}); + var data = {}; + if (!options.firstOnChange) { + var commands = this._generateX2ManyCommands(record, options); + data = _.extend(this.get(record.id, {raw: true}).data, commands); + // 'display_name' is automatically added to the list of fields to fetch, + // when fetching a record, even if it doesn't appear in the view. However, + // only the fields in the view must be passed to the onchange RPC, so we + // remove it from the data sent by RPC if it isn't in the view. + var hasDisplayName = _.some(record.fieldsInfo, function (fieldsInfo) { + return 'display_name' in fieldsInfo; + }); + if (!hasDisplayName) { + delete data.display_name; + } + } + + // one2many records have a parentID + if (record.parentID) { + var parent = this.localData[record.parentID]; + // parent is the list element containing all the records in the + // one2many and parent.parentID is the ID of the main record + // if there is a relation field, this means that record is an elem + // in a one2many. The relation field is the corresponding many2one + if (parent.parentID && parent.relationField) { + var parentRecord = this.localData[parent.parentID]; + data[parent.relationField] = this._generateOnChangeData(parentRecord); + } + } + + return data; + }, + /** + * Read all x2many fields and generate the commands for the server to create + * or write them... + * + * @param {Object} record + * @param {Object} [options] + * @param {string} [options.fieldNames] if given, generates the commands for + * these fields only + * @param {boolean} [changesOnly=false] if true, only generates commands for + * fields that have changed + * @param {boolean} [options.withReadonly=false] if false, doesn't generate + * changes for readonly fields in commands + * @returns {Object} a map from some field names to commands + */ + _generateX2ManyCommands: function (record, options) { + var self = this; + options = options || {}; + var fields = record.fields; + if (options.fieldNames) { + fields = _.pick(fields, options.fieldNames); + } + var commands = {}; + var data = _.extend({}, record.data, record._changes); + var type; + for (var fieldName in fields) { + type = fields[fieldName].type; + + if (type === 'many2many' || type === 'one2many') { + if (!data[fieldName]) { + // skip if this field is empty + continue; + } + commands[fieldName] = []; + var list = this.localData[data[fieldName]]; + if (options.changesOnly && (!list._changes || !list._changes.length)) { + // if only changes are requested, skip if there is no change + continue; + } + var oldResIDs = list.res_ids.slice(0); + var relRecordAdded = []; + var relRecordUpdated = []; + _.each(list._changes, function (change) { + if (change.operation === 'ADD' && change.id) { + relRecordAdded.push(self.localData[change.id]); + } else if (change.operation === 'UPDATE' && !self.isNew(change.id)) { + // ignore new records that would have been updated + // afterwards, as all their changes would already + // be aggregated in the CREATE command + relRecordUpdated.push(self.localData[change.id]); + } + }); + list = this._applyX2ManyOperations(list); + this._sortList(list); + if (type === 'many2many' || list._forceM2MLink) { + var relRecordCreated = _.filter(relRecordAdded, function (rec) { + return typeof rec.res_id === 'string'; + }); + var realIDs = _.difference(list.res_ids, _.pluck(relRecordCreated, 'res_id')); + // deliberately generate a single 'replace' command instead + // of a 'delete' and a 'link' commands with the exact diff + // because 1) performance-wise it doesn't change anything + // and 2) to guard against concurrent updates (policy: force + // a complete override of the actual value of the m2m) + commands[fieldName].push(x2ManyCommands.replace_with(realIDs)); + _.each(relRecordCreated, function (relRecord) { + var changes = self._generateChanges(relRecord, options); + commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); + }); + // generate update commands for records that have been + // updated (it may happen with editable lists) + _.each(relRecordUpdated, function (relRecord) { + var changes = self._generateChanges(relRecord, options); + if (!_.isEmpty(changes)) { + var command = x2ManyCommands.update(relRecord.res_id, changes); + commands[fieldName].push(command); + } + }); + } else if (type === 'one2many') { + var removedIds = _.difference(oldResIDs, list.res_ids); + var addedIds = _.difference(list.res_ids, oldResIDs); + var keptIds = _.intersection(oldResIDs, list.res_ids); + + // the didChange variable keeps track of the fact that at + // least one id was updated + var didChange = false; + var changes, command, relRecord; + for (var i = 0; i < list.res_ids.length; i++) { + if (_.contains(keptIds, list.res_ids[i])) { + // this is an id that already existed + relRecord = _.findWhere(relRecordUpdated, {res_id: list.res_ids[i]}); + changes = relRecord ? this._generateChanges(relRecord, options) : {}; + if (!_.isEmpty(changes)) { + command = x2ManyCommands.update(relRecord.res_id, changes); + didChange = true; + } else { + command = x2ManyCommands.link_to(list.res_ids[i]); + } + commands[fieldName].push(command); + } else if (_.contains(addedIds, list.res_ids[i])) { + // this is a new id (maybe existing in DB, but new in JS) + relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]}); + if (!relRecord) { + commands[fieldName].push(x2ManyCommands.link_to(list.res_ids[i])); + continue; + } + changes = this._generateChanges(relRecord, options); + if (!this.isNew(relRecord.id)) { + // the subrecord already exists in db + commands[fieldName].push(x2ManyCommands.link_to(relRecord.res_id)); + if (this.isDirty(relRecord.id)) { + delete changes.id; + commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, changes)); + } + } else { + // the subrecord is new, so create it + + // we may have received values from an onchange for fields that are + // not in the view, and that we don't even know, as we don't have the + // fields_get of models of related fields. We save those values + // anyway, but for many2ones, we have to extract the id from the pair + // [id, display_name] + const rawChangesEntries = Object.entries(relRecord._rawChanges); + for (const [fieldName, value] of rawChangesEntries) { + const isMany2OneValue = Array.isArray(value) && + value.length === 2 && + Number.isInteger(value[0]) && + typeof value[1] === 'string'; + changes[fieldName] = isMany2OneValue ? value[0] : value; + } + + commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); + } + } + } + if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) { + // in this situation, we have no changed ids, no added + // ids and no removed ids, so we can safely ignore the + // last changes + commands[fieldName] = []; + } + // add delete commands + for (i = 0; i < removedIds.length; i++) { + if (list._forceM2MUnlink) { + commands[fieldName].push(x2ManyCommands.forget(removedIds[i])); + } else { + commands[fieldName].push(x2ManyCommands.delete(removedIds[i])); + } + } + } + } + } + return commands; + }, + /** + * Every RPC done by the model need to add some context, which is a + * combination of the context of the session, of the record/list, and/or of + * the concerned field. This method combines all these contexts and evaluate + * them with the proper evalcontext. + * + * @param {Object} element an element from the localData + * @param {Object} [options] + * @param {string|Object} [options.additionalContext] + * another context to evaluate and merge to the returned context + * @param {string} [options.fieldName] + * if given, this field's context is added to the context, instead of + * the element's context (except if options.full is true) + * @param {boolean} [options.full=false] + * if true or nor fieldName or additionalContext given in options, + * the element's context is added to the context + * @returns {Object} the evaluated context + */ + _getContext: function (element, options) { + options = options || {}; + var context = new Context(session.user_context); + context.set_eval_context(this._getEvalContext(element)); + + if (options.full || !(options.fieldName || options.additionalContext)) { + context.add(element.context); + } + if (options.fieldName) { + var viewType = options.viewType || element.viewType; + var fieldInfo = element.fieldsInfo[viewType][options.fieldName]; + if (fieldInfo && fieldInfo.context) { + context.add(fieldInfo.context); + } else { + var fieldParams = element.fields[options.fieldName]; + if (fieldParams.context) { + context.add(fieldParams.context); + } + } + } + if (options.additionalContext) { + context.add(options.additionalContext); + } + if (element.rawContext) { + var rawContext = new Context(element.rawContext); + var evalContext = this._getEvalContext(this.localData[element.parentID]); + evalContext.id = evalContext.id || false; + rawContext.set_eval_context(evalContext); + context.add(rawContext); + } + + return context.eval(); + }, + /** + * Collects from a record a list of ids to fetch, according to fieldName, + * and a list of records where to set the result of the fetch. + * + * @param {Object} list a list containing records we want to get the ids, + * it assumes _applyX2ManyOperations and _sort have been already called on + * this list + * @param {string} fieldName + * @return {Object} a list of records and res_ids + */ + _getDataToFetch: function (list, fieldName) { + var self = this; + var field = list.fields[fieldName]; + var fieldInfo = list.fieldsInfo[list.viewType][fieldName]; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; + var fields = view ? view.fields : fieldInfo.relatedFields; + var viewType = view ? view.type : fieldInfo.viewType; + + var toFetch = {}; + + // flattens the list.data ids in a grouped case + let dataPointIds = list.data; + for (let i = 0; i < list.groupedBy.length; i++) { + dataPointIds = dataPointIds.reduce((acc, groupId) => + acc.concat(this.localData[groupId].data), []); + } + + dataPointIds.forEach(function (dataPoint) { + var record = self.localData[dataPoint]; + if (typeof record.data[fieldName] === 'string'){ + // in this case, the value is a local ID, which means that the + // record has already been processed. It can happen for example + // when a user adds a record in a m2m relation, or loads more + // records in a kanban column + return; + } + + _.each(record.data[fieldName], function (id) { + toFetch[id] = toFetch[id] || []; + toFetch[id].push(record); + }); + + var m2mList = self._makeDataPoint({ + fieldsInfo: fieldsInfo, + fields: fields, + modelName: field.relation, + parentID: record.id, + res_ids: record.data[fieldName], + static: true, + type: 'list', + viewType: viewType, + }); + record.data[fieldName] = m2mList.id; + }); + + return toFetch; + }, + /** + * Determines and returns from a list a collection of ids classed by + * their model. + * + * @param {Object} list a valid resource object + * @param {string} fieldName + * @param {Object} [toFetchAcc] an object to store fetching data. Used when + * batching reference across multiple groups. + * [modelName: string]: { + * [recordId: number]: datapointId[] + * } + * @returns {Object} each key represent a model and contain a sub-object + * where each key represent an id (res_id) containing an array of + * webclient id (referred to a datapoint, so not a res_id). + */ + _getDataToFetchByModel: function (list, fieldName, toFetchAcc) { + var self = this; + var toFetch = toFetchAcc || {}; + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + var value = record.data[fieldName]; + // if the reference field has already been fetched, the value is a + // datapoint ID, and in this case there's nothing to do + if (value && !self.localData[value]) { + var model = value.split(',')[0]; + var resID = value.split(',')[1]; + if (!(model in toFetch)) { + toFetch[model] = {}; + } + // there could be multiple datapoints with the same model/resID + if (toFetch[model][resID]) { + toFetch[model][resID].push(dataPoint); + } else { + toFetch[model][resID] = [dataPoint]; + } + } + }); + return toFetch; + }, + /** + * Given a dataPoint of type list (that may be a group), returns an object + * with 'default_' keys to be used to create new records in that group. + * + * @private + * @param {Object} dataPoint + * @returns {Object} + */ + _getDefaultContext: function (dataPoint) { + var defaultContext = {}; + while (dataPoint.parentID) { + var parent = this.localData[dataPoint.parentID]; + var groupByField = parent.groupedBy[0].split(':')[0]; + var value = viewUtils.getGroupValue(dataPoint, groupByField); + if (value) { + defaultContext['default_' + groupByField] = value; + } + dataPoint = parent; + } + return defaultContext; + }, + /** + * Some records are associated to a/some domain(s). This method allows to + * retrieve them, evaluated. + * + * @param {Object} element an element from the localData + * @param {Object} [options] + * @param {string} [options.fieldName] + * the name of the field whose domain needs to be returned + * @returns {Array} the evaluated domain + */ + _getDomain: function (element, options) { + if (options && options.fieldName) { + if (element._domains[options.fieldName]) { + return Domain.prototype.stringToArray( + element._domains[options.fieldName], + this._getEvalContext(element, true) + ); + } + var viewType = options.viewType || element.viewType; + var fieldInfo = element.fieldsInfo[viewType][options.fieldName]; + if (fieldInfo && fieldInfo.domain) { + return Domain.prototype.stringToArray( + fieldInfo.domain, + this._getEvalContext(element, true) + ); + } + var fieldParams = element.fields[options.fieldName]; + if (fieldParams.domain) { + return Domain.prototype.stringToArray( + fieldParams.domain, + this._getEvalContext(element, true) + ); + } + return []; + } + + return Domain.prototype.stringToArray( + element.domain, + this._getEvalContext(element, true) + ); + }, + /** + * Returns the evaluation context that should be used when evaluating the + * context/domain associated to a given element from the localData. + * + * It is actually quite subtle. We need to add some magic keys: active_id + * and active_ids. Also, the session user context is added in the mix to be + * sure. This allows some domains to use the uid key for example + * + * @param {Object} element - an element from the localData + * @param {boolean} [forDomain=false] if true, evaluates x2manys as a list of + * ids instead of a list of commands + * @returns {Object} + */ + _getEvalContext: function (element, forDomain) { + var evalContext = element.type === 'record' ? this._getRecordEvalContext(element, forDomain) : {}; + + if (element.parentID) { + var parent = this.localData[element.parentID]; + if (parent.type === 'list' && parent.parentID) { + parent = this.localData[parent.parentID]; + } + if (parent.type === 'record') { + evalContext.parent = this._getRecordEvalContext(parent, forDomain); + } + } + // Uses "current_company_id" because "company_id" would conflict with all the company_id fields + // in general, the actual "company_id" field of the form should be used for m2o domains, not this fallback + let current_company_id; + if (session.user_context.allowed_company_ids) { + current_company_id = session.user_context.allowed_company_ids[0]; + } else { + current_company_id = session.user_companies ? + session.user_companies.current_company[0] : + false; + } + return Object.assign( + { + active_id: evalContext.id || false, + active_ids: evalContext.id ? [evalContext.id] : [], + active_model: element.model, + current_company_id, + id: evalContext.id || false, + }, + pyUtils.context(), + session.user_context, + element.context, + evalContext, + ); + }, + /** + * Returns the list of field names of the given element according to its + * default view type. + * + * @param {Object} element an element from the localData + * @param {Object} [options] + * @param {Object} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {string[]} the list of field names + */ + _getFieldNames: function (element, options) { + var fieldsInfo = element.fieldsInfo; + var viewType = options && options.viewType || element.viewType; + return Object.keys(fieldsInfo && fieldsInfo[viewType] || {}); + }, + /** + * Get many2one fields names in a datapoint. This is useful in order to + * fetch their names in the case of a default_get. + * + * @private + * @param {Object} datapoint a valid resource object + * @returns {string[]} list of field names that are many2one + */ + _getMany2OneFieldNames: function (datapoint) { + var many2ones = []; + _.each(datapoint.fields, function (field, name) { + if (field.type === 'many2one') { + many2ones.push(name); + } + }); + return many2ones; + }, + /** + * Evaluate the record evaluation context. This method is supposed to be + * called by _getEvalContext. It basically only generates a dictionary of + * current values for the record, with commands for x2manys fields. + * + * @param {Object} record an element of type 'record' + * @param {boolean} [forDomain=false] if true, x2many values are a list of + * ids instead of a list of commands + * @returns Object + */ + _getRecordEvalContext: function (record, forDomain) { + var self = this; + var relDataPoint; + var context = _.extend({}, record.data, record._changes); + + // calls _generateX2ManyCommands for a given field, and returns the array of commands + function _generateX2ManyCommands(fieldName) { + var commands = self._generateX2ManyCommands(record, {fieldNames: [fieldName]}); + return commands[fieldName]; + } + + for (var fieldName in context) { + var field = record.fields[fieldName]; + if (context[fieldName] === null) { + context[fieldName] = false; + } + if (!field || field.name === 'id') { + continue; + } + if (field.type === 'date' || field.type === 'datetime') { + if (context[fieldName]) { + context[fieldName] = JSON.parse(JSON.stringify(context[fieldName])); + } + continue; + } + if (field.type === 'many2one') { + relDataPoint = this.localData[context[fieldName]]; + context[fieldName] = relDataPoint ? relDataPoint.res_id : false; + continue; + } + if (field.type === 'one2many' || field.type === 'many2many') { + var ids; + if (!context[fieldName] || _.isArray(context[fieldName])) { // no dataPoint created yet + ids = context[fieldName] ? context[fieldName].slice(0) : []; + } else { + relDataPoint = this._applyX2ManyOperations(this.localData[context[fieldName]]); + ids = relDataPoint.res_ids.slice(0); + } + if (!forDomain) { + // when sent to the server, the x2manys values must be a list + // of commands in a context, but the list of ids in a domain + ids.toJSON = _generateX2ManyCommands.bind(null, fieldName); + } else if (field.type === 'one2many') { // Ids are evaluated as a list of ids + /* Filtering out virtual ids from the ids list + * The server will crash if there are virtual ids in there + * The webClient doesn't do literal id list comparison like ids == list + * Only relevant in o2m: m2m does create actual records in db + */ + ids = _.filter(ids, function (id) { + return typeof id !== 'string'; + }); + } + context[fieldName] = ids; + } + + } + return context; + }, + /** + * Invalidates the DataManager's cache if the main model (i.e. the model of + * its root parent) of the given dataPoint is a model in 'noCacheModels'. + * + * Reloads the currencies if the main model is 'res.currency'. + * Reloads the webclient if we modify a res.company, to (un)activate the + * multi-company environment if we are not in a tour test. + * + * @private + * @param {Object} dataPoint + */ + _invalidateCache: function (dataPoint) { + while (dataPoint.parentID) { + dataPoint = this.localData[dataPoint.parentID]; + } + if (dataPoint.model === 'res.currency') { + session.reloadCurrencies(); + } + if (dataPoint.model === 'res.company' && !localStorage.getItem('running_tour')) { + this.do_action('reload_context'); + } + if (_.contains(this.noCacheModels, dataPoint.model)) { + core.bus.trigger('clear_cache'); + } + }, + /** + * Returns true if the field is protected against changes, looking for a + * readonly modifier unless there is a force_save modifier (checking first + * in the modifiers, and if there is no readonly modifier, checking the + * readonly attribute of the field). + * + * @private + * @param {Object} record an element from the localData + * @param {string} fieldName + * @param {string} [viewType] current viewType. If not set, we will assume + * main viewType from the record + * @returns {boolean} + */ + _isFieldProtected: function (record, fieldName, viewType) { + viewType = viewType || record.viewType; + var fieldInfo = viewType && record.fieldsInfo && record.fieldsInfo[viewType][fieldName]; + if (fieldInfo) { + var rawModifiers = fieldInfo.modifiers || {}; + var modifiers = this._evalModifiers(record, _.pick(rawModifiers, 'readonly')); + return modifiers.readonly && !fieldInfo.force_save; + } else { + return false; + } + }, + /** + * Returns true iff value is considered to be set for the given field's type. + * + * @private + * @param {any} value a value for the field + * @param {string} fieldType a type of field + * @returns {boolean} + */ + _isFieldSet: function (value, fieldType) { + switch (fieldType) { + case 'boolean': + return true; + case 'one2many': + case 'many2many': + return value.length > 0; + default: + return value !== false; + } + }, + /** + * return true if a list element is 'valid'. Such an element is valid if it + * has no sub record with an unset required field. + * + * This method is meant to be used to check if a x2many change will trigger + * an onchange. + * + * @param {string} id id for a local resource of type 'list'. This is + * assumed to be a list element for an x2many + * @returns {boolean} + */ + _isX2ManyValid: function (id) { + var self = this; + var isValid = true; + var element = this.localData[id]; + _.each(element._changes, function (command) { + if (command.operation === 'DELETE' || + command.operation === 'FORGET' || + (command.operation === 'ADD' && !command.isNew)|| + command.operation === 'REMOVE_ALL') { + return; + } + var recordData = self.get(command.id, {raw: true}).data; + var record = self.localData[command.id]; + _.each(element.getFieldNames(), function (fieldName) { + var field = element.fields[fieldName]; + var fieldInfo = element.fieldsInfo[element.viewType][fieldName]; + var rawModifiers = fieldInfo.modifiers || {}; + var modifiers = self._evalModifiers(record, _.pick(rawModifiers, 'required')); + if (modifiers.required && !self._isFieldSet(recordData[fieldName], field.type)) { + isValid = false; + } + }); + }); + return isValid; + }, + /** + * Helper method for the load entry point. + * + * @see load + * + * @param {Object} dataPoint some local resource + * @param {Object} [options] + * @param {string[]} [options.fieldNames] the fields to fetch for a record + * @param {boolean} [options.onlyGroups=false] + * @param {boolean} [options.keepEmptyGroups=false] if set, the groups not + * present in the read_group anymore (empty groups) will stay in the + * datapoint (used to mimic the kanban renderer behaviour for example) + * @returns {Promise} + */ + _load: function (dataPoint, options) { + if (options && options.onlyGroups && + !(dataPoint.type === 'list' && dataPoint.groupedBy.length)) { + return Promise.resolve(dataPoint); + } + + if (dataPoint.type === 'record') { + return this._fetchRecord(dataPoint, options); + } + if (dataPoint.type === 'list' && dataPoint.groupedBy.length) { + return this._readGroup(dataPoint, options); + } + if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) { + return this._fetchUngroupedList(dataPoint, options); + } + }, + /** + * Turns a bag of properties into a valid local resource. Also, register + * the resource in the localData object. + * + * @param {Object} params + * @param {Object} [params.aggregateValues={}] + * @param {Object} [params.context={}] context of the action + * @param {integer} [params.count=0] number of record being manipulated + * @param {Object|Object[]} [params.data={}|[]] data of the record + * @param {*[]} [params.domain=[]] + * @param {Object} params.fields contains the description of each field + * @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field + * @param {Object[]} [params.fieldNames] the name of fields to load, the list + * of all fields by default + * @param {string[]} [params.groupedBy=[]] + * @param {boolean} [params.isOpen] + * @param {integer} params.limit max number of records shown on screen (pager size) + * @param {string} params.modelName + * @param {integer} [params.offset] + * @param {boolean} [params.openGroupByDefault] + * @param {Object[]} [params.orderedBy=[]] + * @param {integer[]} [params.orderedResIDs] + * @param {string} [params.parentID] model name ID of the parent model + * @param {Object} [params.rawContext] + * @param {[type]} [params.ref] + * @param {string} [params.relationField] + * @param {integer|null} [params.res_id] actual id of record in the server + * @param {integer[]} [params.res_ids] context in which the data point is used, from a list of res_id + * @param {boolean} [params.static=false] + * @param {string} [params.type='record'|'list'] + * @param {[type]} [params.value] + * @param {string} [params.viewType] the type of the view, e.g. 'list' or 'form' + * @returns {Object} the resource created + */ + _makeDataPoint: function (params) { + var type = params.type || ('domain' in params && 'list') || 'record'; + var res_id, value; + var res_ids = params.res_ids || []; + var data = params.data || (type === 'record' ? {} : []); + var context = params.context; + if (type === 'record') { + res_id = params.res_id || (params.data && params.data.id); + if (res_id) { + data.id = res_id; + } else { + res_id = _.uniqueId('virtual_'); + } + // it doesn't make sense for a record datapoint to have those keys + // besides, it will mess up x2m and actions down the line + context = _.omit(context, ['orderedBy', 'group_by']); + } else { + var isValueArray = params.value instanceof Array; + res_id = isValueArray ? params.value[0] : undefined; + value = isValueArray ? params.value[1] : params.value; + } + + var fields = _.extend({ + display_name: {type: 'char'}, + id: {type: 'integer'}, + }, params.fields); + + var dataPoint = { + _cache: type === 'list' ? {} : undefined, + _changes: null, + _domains: {}, + _rawChanges: {}, + aggregateValues: params.aggregateValues || {}, + context: context, + count: params.count || res_ids.length, + data: data, + domain: params.domain || [], + fields: fields, + fieldsInfo: params.fieldsInfo, + groupedBy: params.groupedBy || [], + groupsCount: 0, + groupsLimit: type === 'list' && params.groupsLimit || null, + groupsOffset: 0, + id: `${params.modelName}_${++this.__id}`, + isOpen: params.isOpen, + limit: type === 'record' ? 1 : (params.limit || Number.MAX_SAFE_INTEGER), + loadMoreOffset: 0, + model: params.modelName, + offset: params.offset || (type === 'record' ? _.indexOf(res_ids, res_id) : 0), + openGroupByDefault: params.openGroupByDefault, + orderedBy: params.orderedBy || [], + orderedResIDs: params.orderedResIDs, + parentID: params.parentID, + rawContext: params.rawContext, + ref: params.ref || res_id, + relationField: params.relationField, + res_id: res_id, + res_ids: res_ids, + specialData: {}, + _specialDataCache: {}, + static: params.static || false, + type: type, // 'record' | 'list' + value: value, + viewType: params.viewType, + }; + + // _editionViewType is a dict whose keys are field names and which is populated when a field + // is edited with the viewType as value. This is useful for one2manys to determine whether + // or not a field is readonly (using the readonly modifiers of the view in which the field + // has been edited) + dataPoint._editionViewType = {}; + + dataPoint.evalModifiers = this._evalModifiers.bind(this, dataPoint); + dataPoint.getContext = this._getContext.bind(this, dataPoint); + dataPoint.getDomain = this._getDomain.bind(this, dataPoint); + dataPoint.getFieldNames = this._getFieldNames.bind(this, dataPoint); + dataPoint.isDirty = this.isDirty.bind(this, dataPoint.id); + + this.localData[dataPoint.id] = dataPoint; + + return dataPoint; + }, + /** + * When one needs to create a record from scratch, a not so simple process + * needs to be done: + * - call the /default_get route to get default values + * - fetch all relational data + * - apply all onchanges if necessary + * - fetch all relational data + * + * This method tries to optimize the process as much as possible. Also, + * it is quite horrible and should be refactored at some point. + * + * @private + * @param {any} params + * @param {string} modelName model name + * @param {boolean} [params.allowWarning=false] if true, the default record + * operation can complete, even if a warning is raised + * @param {Object} params.context the context for the new record + * @param {Object} params.fieldsInfo contains the fieldInfo of each view, + * for each field + * @param {Object} params.fields contains the description of each field + * @param {Object} params.context the context for the new record + * @param {string} params.viewType the key in fieldsInfo of the fields to load + * @returns {Promise<string>} resolves to the id for the created resource + */ + async _makeDefaultRecord(modelName, params) { + var targetView = params.viewType; + var fields = params.fields; + var fieldsInfo = params.fieldsInfo; + var fieldNames = Object.keys(fieldsInfo[targetView]); + + // Fields that are present in the originating view, that need to be initialized + // Hence preventing their value to crash when getting back to the originating view + var parentRecord = params.parentID && this.localData[params.parentID].type === 'list' ? this.localData[params.parentID] : null; + + if (parentRecord && parentRecord.viewType in parentRecord.fieldsInfo) { + var originView = parentRecord.viewType; + fieldNames = _.union(fieldNames, Object.keys(parentRecord.fieldsInfo[originView])); + fieldsInfo[targetView] = _.defaults({}, fieldsInfo[targetView], parentRecord.fieldsInfo[originView]); + fields = _.defaults({}, fields, parentRecord.fields); + } + + var record = this._makeDataPoint({ + modelName: modelName, + fields: fields, + fieldsInfo: fieldsInfo, + context: params.context, + parentID: params.parentID, + res_ids: params.res_ids, + viewType: targetView, + }); + + await this.generateDefaultValues(record.id, {}, { fieldNames }); + try { + await this._performOnChange(record, [], { firstOnChange: true }); + } finally { + if (record._warning && params.allowWarning) { + delete record._warning; + } + } + if (record._warning) { + return Promise.reject(); + } + + // We want to overwrite the default value of the handle field (if any), + // in order for new lines to be added at the correct position. + // -> This is a rare case where the defaul_get from the server + // will be ignored by the view for a certain field (usually "sequence"). + var overrideDefaultFields = this._computeOverrideDefaultFields(params.parentID, params.position); + if (overrideDefaultFields.field) { + record._changes[overrideDefaultFields.field] = overrideDefaultFields.value; + } + + // fetch additional data (special data and many2one namegets for "always_reload" fields) + await this._postprocess(record); + // save initial changes, so they can be restored later, if we need to discard + this.save(record.id, { savePoint: true }); + return record.id; + }, + /** + * parse the server values to javascript framwork + * + * @param {[string]} fieldNames + * @param {Object} element the dataPoint used as parent for the created + * dataPoints + * @param {Object} data the server data to parse + */ + _parseServerData: function (fieldNames, element, data) { + var self = this; + _.each(fieldNames, function (fieldName) { + var field = element.fields[fieldName]; + var val = data[fieldName]; + if (field.type === 'many2one') { + // process many2one: split [id, nameget] and create corresponding record + if (val !== false) { + // the many2one value is of the form [id, display_name] + var r = self._makeDataPoint({ + modelName: field.relation, + fields: { + display_name: {type: 'char'}, + id: {type: 'integer'}, + }, + data: { + display_name: val[1], + id: val[0], + }, + parentID: element.id, + }); + data[fieldName] = r.id; + } else { + // no value for the many2one + data[fieldName] = false; + } + } else { + data[fieldName] = self._parseServerValue(field, val); + } + }); + }, + /** + * This method is quite important: it is supposed to perform the /onchange + * rpc and apply the result. + * + * The changes that triggered the onchange are assumed to have already been + * applied to the record. + * + * @param {Object} record + * @param {string[]} fields changed fields (empty list in the case of first + * onchange) + * @param {Object} [options={}] + * @param {string} [options.viewType] current viewType. If not set, we will assume + * main viewType from the record + * @param {boolean} [options.firstOnChange=false] set to true if this is the + * first onchange + * @returns {Promise} + */ + async _performOnChange(record, fields, options = {}) { + const firstOnChange = options.firstOnChange; + let { hasOnchange, onchangeSpec } = this._buildOnchangeSpecs(record, options.viewType); + if (!firstOnChange && !hasOnchange) { + return; + } + var idList = record.data.id ? [record.data.id] : []; + const ctxOptions = { + full: true, + }; + if (fields.length === 1) { + fields = fields[0]; + // if only one field changed, add its context to the RPC context + ctxOptions.fieldName = fields; + } + var context = this._getContext(record, ctxOptions); + var currentData = this._generateOnChangeData(record, { + changesOnly: false, + firstOnChange, + }); + + const result = await this._rpc({ + model: record.model, + method: 'onchange', + args: [idList, currentData, fields, onchangeSpec], + context: context, + }); + if (!record._changes) { + // if the _changes key does not exist anymore, it means that + // it was removed by discarding the changes after the rpc + // to onchange. So, in that case, the proper response is to + // ignore the onchange. + return; + } + if (result.warning) { + this.trigger_up('warning', result.warning); + record._warning = true; + } + if (result.domain) { + record._domains = Object.assign(record._domains, result.domain); + } + await this._applyOnChange(result.value, record, { firstOnChange }); + return result; + }, + /** + * This function accumulates RPC requests done in the same call stack, and + * performs them in the next micro task tick so that similar requests can be + * batched in a single RPC. + * + * For now, only 'read' calls are supported. + * + * @private + * @param {Object} params + * @returns {Promise} + */ + _performRPC: function (params) { + var self = this; + + // save the RPC request + var request = _.extend({}, params); + var prom = new Promise(function (resolve, reject) { + request.resolve = resolve; + request.reject = reject; + }); + this.batchedRPCsRequests.push(request); + + // empty the pool of RPC requests in the next micro tick + Promise.resolve().then(function () { + if (!self.batchedRPCsRequests.length) { + // pool has already been processed + return; + } + + // reset pool of RPC requests + var batchedRPCsRequests = self.batchedRPCsRequests; + self.batchedRPCsRequests = []; + + // batch similar requests + var batches = {}; + var key; + for (var i = 0; i < batchedRPCsRequests.length; i++) { + var request = batchedRPCsRequests[i]; + key = request.model + ',' + JSON.stringify(request.context); + if (!batches[key]) { + batches[key] = _.extend({}, request, {requests: [request]}); + } else { + batches[key].ids = _.uniq(batches[key].ids.concat(request.ids)); + batches[key].fieldNames = _.uniq(batches[key].fieldNames.concat(request.fieldNames)); + batches[key].requests.push(request); + } + } + + // perform batched RPCs + function onSuccess(batch, results) { + for (var i = 0; i < batch.requests.length; i++) { + var request = batch.requests[i]; + var fieldNames = request.fieldNames.concat(['id']); + var filteredResults = results.filter(function (record) { + return request.ids.indexOf(record.id) >= 0; + }).map(function (record) { + return _.pick(record, fieldNames); + }); + request.resolve(filteredResults); + } + } + function onFailure(batch, error) { + for (var i = 0; i < batch.requests.length; i++) { + var request = batch.requests[i]; + request.reject(error); + } + } + for (key in batches) { + var batch = batches[key]; + self._rpc({ + model: batch.model, + method: 'read', + args: [batch.ids, batch.fieldNames], + context: batch.context, + }).then(onSuccess.bind(null, batch)).guardedCatch(onFailure.bind(null, batch)); + } + }); + + return prom; + }, + /** + * Once a record is created and some data has been fetched, we need to do + * quite a lot of computations to determine what needs to be fetched. This + * method is doing that. + * + * @see _fetchRecord @see _makeDefaultRecord + * + * @param {Object} record + * @param {Object} [options] + * @param {Object} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {Promise<Object>} resolves to the finished resource + */ + _postprocess: function (record, options) { + var self = this; + var viewType = options && options.viewType || record.viewType; + var defs = []; + + _.each(record.getFieldNames(options), function (name) { + var field = record.fields[name]; + var fieldInfo = record.fieldsInfo[viewType][name] || {}; + var options = fieldInfo.options || {}; + if (options.always_reload) { + if (record.fields[name].type === 'many2one') { + const _changes = record._changes || {}; + const relRecordId = _changes[name] || record.data[name]; + if (!relRecordId) { + return; // field is unset, no need to do the name_get + } + var relRecord = self.localData[relRecordId]; + defs.push(self._rpc({ + model: field.relation, + method: 'name_get', + args: [relRecord.data.id], + context: self._getContext(record, {fieldName: name, viewType: viewType}), + }) + .then(function (result) { + relRecord.data.display_name = result[0][1]; + })); + } + } + }); + + defs.push(this._fetchSpecialData(record, options)); + + return Promise.all(defs).then(function () { + return record; + }); + }, + /** + * Process x2many commands in a default record by transforming the list of + * commands in operations (pushed in _changes) and fetch the related + * records fields. + * + * Note that this method can be called recursively. + * + * @todo in master: factorize this code with the postprocessing of x2many in + * _applyOnChange + * + * @private + * @param {Object} record + * @param {string} fieldName + * @param {Array[Array]} commands + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {Promise} + */ + _processX2ManyCommands: function (record, fieldName, commands, options) { + var self = this; + options = options || {}; + var defs = []; + var field = record.fields[fieldName]; + var fieldInfo = record.fieldsInfo[options.viewType || record.viewType][fieldName] || {}; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; + var fields = view ? view.fields : fieldInfo.relatedFields; + var viewType = view ? view.type : fieldInfo.viewType; + + // remove default_* keys from parent context to avoid issue of same field name in x2m + var parentContext = _.omit(record.context, function (val, key) { + return _.str.startsWith(key, 'default_'); + }); + var x2manyList = self._makeDataPoint({ + context: parentContext, + fieldsInfo: fieldsInfo, + fields: fields, + limit: fieldInfo.limit, + modelName: field.relation, + parentID: record.id, + rawContext: fieldInfo && fieldInfo.context, + relationField: field.relation_field, + res_ids: [], + static: true, + type: 'list', + viewType: viewType, + }); + record._changes[fieldName] = x2manyList.id; + x2manyList._changes = []; + var many2ones = {}; + var r; + commands = commands || []; // handle false value + var isCommandList = commands.length && _.isArray(commands[0]); + if (!isCommandList) { + commands = [[6, false, commands]]; + } + _.each(commands, function (value) { + // value is a command + if (value[0] === 0) { + // CREATE + r = self._makeDataPoint({ + modelName: x2manyList.model, + context: x2manyList.context, + fieldsInfo: fieldsInfo, + fields: fields, + parentID: x2manyList.id, + viewType: viewType, + }); + r._noAbandon = true; + x2manyList._changes.push({operation: 'ADD', id: r.id}); + x2manyList._cache[r.res_id] = r.id; + + // this is necessary so the fields are initialized + _.each(r.getFieldNames(), function (fieldName) { + r.data[fieldName] = null; + }); + + r._changes = _.defaults(value[2], r.data); + for (var fieldName in r._changes) { + if (!r._changes[fieldName]) { + continue; + } + var isFieldInView = fieldName in r.fields; + if (isFieldInView) { + var field = r.fields[fieldName]; + var fieldType = field.type; + var rec; + if (fieldType === 'many2one') { + rec = self._makeDataPoint({ + context: r.context, + modelName: field.relation, + data: {id: r._changes[fieldName]}, + parentID: r.id, + }); + r._changes[fieldName] = rec.id; + many2ones[fieldName] = true; + } else if (fieldType === 'reference') { + var reference = r._changes[fieldName].split(','); + rec = self._makeDataPoint({ + context: r.context, + modelName: reference[0], + data: {id: parseInt(reference[1])}, + parentID: r.id, + }); + r._changes[fieldName] = rec.id; + many2ones[fieldName] = true; + } else if (_.contains(['one2many', 'many2many'], fieldType)) { + var x2mCommands = value[2][fieldName]; + defs.push(self._processX2ManyCommands(r, fieldName, x2mCommands)); + } else { + r._changes[fieldName] = self._parseServerValue(field, r._changes[fieldName]); + } + } + } + } + if (value[0] === 6) { + // REPLACE_WITH + _.each(value[2], function (res_id) { + x2manyList._changes.push({operation: 'ADD', resID: res_id}); + }); + var def = self._readUngroupedList(x2manyList).then(function () { + return Promise.all([ + self._fetchX2ManysBatched(x2manyList), + self._fetchReferencesBatched(x2manyList) + ]); + }); + defs.push(def); + } + }); + + // fetch many2ones display_name + _.each(_.keys(many2ones), function (name) { + defs.push(self._fetchNameGets(x2manyList, name)); + }); + + return Promise.all(defs); + }, + /** + * Reads data from server for all missing fields. + * + * @private + * @param {Object} list a valid resource object + * @param {interger[]} resIDs + * @param {string[]} fieldNames to check and read if missing + * @returns {Promise<Object>} + */ + _readMissingFields: function (list, resIDs, fieldNames) { + var self = this; + + var missingIDs = []; + for (var i = 0, len = resIDs.length; i < len; i++) { + var resId = resIDs[i]; + var dataPointID = list._cache[resId]; + if (!dataPointID) { + missingIDs.push(resId); + continue; + } + var record = self.localData[dataPointID]; + var data = _.extend({}, record.data, record._changes); + if (_.difference(fieldNames, _.keys(data)).length) { + missingIDs.push(resId); + } + } + + var def; + if (missingIDs.length && fieldNames.length) { + def = self._performRPC({ + context: list.getContext(), + fieldNames: fieldNames, + ids: missingIDs, + method: 'read', + model: list.model, + }); + } else { + def = Promise.resolve(_.map(missingIDs, function (id) { + return {id:id}; + })); + } + return def.then(function (records) { + _.each(resIDs, function (id) { + var dataPoint; + var data = _.findWhere(records, {id: id}); + if (id in list._cache) { + dataPoint = self.localData[list._cache[id]]; + if (data) { + self._parseServerData(fieldNames, dataPoint, data); + _.extend(dataPoint.data, data); + } + } else { + dataPoint = self._makeDataPoint({ + context: list.getContext(), + data: data, + fieldsInfo: list.fieldsInfo, + fields: list.fields, + modelName: list.model, + parentID: list.id, + viewType: list.viewType, + }); + self._parseServerData(fieldNames, dataPoint, dataPoint.data); + + // add many2one records + list._cache[id] = dataPoint.id; + } + // set the dataPoint id in potential 'ADD' operation adding the current record + _.each(list._changes, function (change) { + if (change.operation === 'ADD' && !change.id && change.resID === id) { + change.id = dataPoint.id; + } + }); + }); + return list; + }); + }, + /** + * For a grouped list resource, this method fetches all group data by + * performing a /read_group. It also tries to read open subgroups if they + * were open before. + * + * @param {Object} list valid resource object + * @param {Object} [options] @see _load + * @returns {Promise<Object>} resolves to the fetched group object + */ + _readGroup: function (list, options) { + var self = this; + options = options || {}; + var groupByField = list.groupedBy[0]; + var rawGroupBy = groupByField.split(':')[0]; + var fields = _.uniq(list.getFieldNames().concat(rawGroupBy)); + var orderedBy = _.filter(list.orderedBy, function (order) { + return order.name === rawGroupBy || list.fields[order.name].group_operator !== undefined; + }); + var openGroupsLimit = list.groupsLimit || self.OPEN_GROUP_LIMIT; + var expand = list.openGroupByDefault && options.fetchRecordsWithGroups; + return this._rpc({ + model: list.model, + method: 'web_read_group', + fields: fields, + domain: list.domain, + context: list.context, + groupBy: list.groupedBy, + limit: list.groupsLimit, + offset: list.groupsOffset, + orderBy: orderedBy, + lazy: true, + expand: expand, + expand_limit: expand ? list.limit : null, + expand_orderby: expand ? list.orderedBy : null, + }) + .then(function (result) { + var groups = result.groups; + list.groupsCount = result.length; + var previousGroups = _.map(list.data, function (groupID) { + return self.localData[groupID]; + }); + list.data = []; + list.count = 0; + var defs = []; + var openGroupCount = 0; + + _.each(groups, function (group) { + var aggregateValues = {}; + _.each(group, function (value, key) { + if (_.contains(fields, key) && key !== groupByField && + AGGREGATABLE_TYPES.includes(list.fields[key].type)) { + aggregateValues[key] = value; + } + }); + // When a view is grouped, we need to display the name of each group in + // the 'title'. + var value = group[groupByField]; + if (list.fields[rawGroupBy].type === "selection") { + var choice = _.find(list.fields[rawGroupBy].selection, function (c) { + return c[0] === value; + }); + value = choice ? choice[1] : false; + } + var newGroup = self._makeDataPoint({ + modelName: list.model, + count: group[rawGroupBy + '_count'], + domain: group.__domain, + context: list.context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + value: value, + aggregateValues: aggregateValues, + groupedBy: list.groupedBy.slice(1), + orderedBy: list.orderedBy, + orderedResIDs: list.orderedResIDs, + limit: list.limit, + openGroupByDefault: list.openGroupByDefault, + parentID: list.id, + type: 'list', + viewType: list.viewType, + }); + var oldGroup = _.find(previousGroups, function (g) { + return g.res_id === newGroup.res_id && g.value === newGroup.value; + }); + if (oldGroup) { + delete self.localData[newGroup.id]; + // restore the internal state of the group + var updatedProps = _.pick(oldGroup, 'isOpen', 'offset', 'id'); + if (options.onlyGroups || oldGroup.isOpen && newGroup.groupedBy.length) { + // If the group is opened and contains subgroups, + // also keep its data to keep internal state of + // sub-groups + // Also keep data if we only reload groups' own data + updatedProps.data = oldGroup.data; + if (options.onlyGroups) { + // keep count and res_ids as in this case the group + // won't be search_read again. This situation happens + // when using kanban quick_create where the record is manually + // added to the datapoint before getting here. + updatedProps.res_ids = oldGroup.res_ids; + updatedProps.count = oldGroup.count; + } + } + _.extend(newGroup, updatedProps); + // set the limit such that all previously loaded records + // (e.g. if we are coming back to the kanban view from a + // form view) are reloaded + newGroup.limit = oldGroup.limit + oldGroup.loadMoreOffset; + self.localData[newGroup.id] = newGroup; + } else if (!newGroup.openGroupByDefault || openGroupCount >= openGroupsLimit) { + newGroup.isOpen = false; + } else if ('__fold' in group) { + newGroup.isOpen = !group.__fold; + } else { + // open the group iff it is a first level group + newGroup.isOpen = !self.localData[newGroup.parentID].parentID; + } + list.data.push(newGroup.id); + list.count += newGroup.count; + if (newGroup.isOpen && newGroup.count > 0) { + openGroupCount++; + if (group.__data) { + // bypass the search_read when the group's records have been obtained + // by the call to 'web_read_group' (see @_searchReadUngroupedList) + newGroup.__data = group.__data; + } + options = _.defaults({enableRelationalFetch: false}, options); + defs.push(self._load(newGroup, options)); + } + }); + if (options.keepEmptyGroups) { + // Find the groups that were available in a previous + // readGroup but are not there anymore. + // Note that these groups are put after existing groups so + // the order is not conserved. A sort *might* be useful. + var emptyGroupsIDs = _.difference(_.pluck(previousGroups, 'id'), list.data); + _.each(emptyGroupsIDs, function (groupID) { + list.data.push(groupID); + var emptyGroup = self.localData[groupID]; + // this attribute hasn't been updated in the previous + // loop for empty groups + emptyGroup.aggregateValues = {}; + }); + } + + return Promise.all(defs).then(function (groups) { + if (!options.onlyGroups) { + // generate the res_ids of the main list, being the concatenation + // of the fetched res_ids in each group + list.res_ids = _.flatten(_.map(groups, function (group) { + return group ? group.res_ids : []; + })); + } + return list; + }).then(function () { + return Promise.all([ + self._fetchX2ManysSingleBatch(list), + self._fetchReferencesSingleBatch(list) + ]).then(function () { + return list; + }); + }); + }); + }, + /** + * For 'static' list, such as one2manys in a form view, we can do a /read + * instead of a /search_read. + * + * @param {Object} list a valid resource object + * @returns {Promise<Object>} resolves to the fetched list object + */ + _readUngroupedList: function (list) { + var self = this; + var def = Promise.resolve(); + + // generate the current count and res_ids list by applying the changes + list = this._applyX2ManyOperations(list); + + // for multi-pages list datapoints, we might need to read the + // order field first to apply the order on all pages + if (list.res_ids.length > list.limit && list.orderedBy.length) { + if (!list.orderedResIDs) { + var fieldNames = _.pluck(list.orderedBy, 'name'); + def = this._readMissingFields(list, _.filter(list.res_ids, _.isNumber), fieldNames); + } + def.then(function () { + self._sortList(list); + }); + } + return def.then(function () { + var resIDs = []; + var currentResIDs = list.res_ids; + // if new records have been added to the list, their virtual ids have + // been pushed at the end of res_ids (or at the beginning, depending + // on the editable property), ignoring completely the current page + // where the records have actually been created ; for that reason, + // we use orderedResIDs which is a freezed order with the virtual ids + // at the correct position where they were actually inserted ; however, + // when we use orderedResIDs, we must filter out ids that are not in + // res_ids, which correspond to records that have been removed from + // the relation (this information being taken into account in res_ids + // but not in orderedResIDs) + if (list.orderedResIDs) { + currentResIDs = list.orderedResIDs.filter(function (resID) { + return list.res_ids.indexOf(resID) >= 0; + }); + } + var currentCount = currentResIDs.length; + var upperBound = list.limit ? Math.min(list.offset + list.limit, currentCount) : currentCount; + var fieldNames = list.getFieldNames(); + for (var i = list.offset; i < upperBound; i++) { + var resId = currentResIDs[i]; + if (_.isNumber(resId)) { + resIDs.push(resId); + } + } + return self._readMissingFields(list, resIDs, fieldNames).then(function () { + if (list.res_ids.length <= list.limit) { + self._sortList(list); + } else { + // sortList has already been applied after first the read + self._setDataInRange(list); + } + return list; + }); + }); + }, + /** + * Reload all data for a given resource + * + * @private + * @param {string} id local id for a resource + * @param {Object} [options] + * @param {boolean} [options.keepChanges=false] if true, doesn't discard the + * changes on the record before reloading it + * @returns {Promise<string>} resolves to the id of the resource + */ + _reload: function (id, options) { + options = options || {}; + var element = this.localData[id]; + + if (element.type === 'record') { + if (!options.currentId && (('currentId' in options) || this.isNew(id))) { + var params = { + context: element.context, + fieldsInfo: element.fieldsInfo, + fields: element.fields, + viewType: element.viewType, + allowWarning: true, + }; + return this._makeDefaultRecord(element.model, params); + } + if (!options.keepChanges) { + this.discardChanges(id, {rollback: false}); + } + } else if (element._changes) { + delete element.tempLimitIncrement; + _.each(element._changes, function (change) { + delete change.isNew; + }); + } + + if (options.context !== undefined) { + element.context = options.context; + } + if (options.orderedBy !== undefined) { + element.orderedBy = (options.orderedBy.length && options.orderedBy) || element.orderedBy; + } + if (options.domain !== undefined) { + element.domain = options.domain; + } + if (options.groupBy !== undefined) { + element.groupedBy = options.groupBy; + } + if (options.limit !== undefined) { + element.limit = options.limit; + } + if (options.offset !== undefined) { + this._setOffset(element.id, options.offset); + } + if (options.groupsLimit !== undefined) { + element.groupsLimit = options.groupsLimit; + } + if (options.groupsOffset !== undefined) { + element.groupsOffset = options.groupsOffset; + } + if (options.loadMoreOffset !== undefined) { + element.loadMoreOffset = options.loadMoreOffset; + } else { + // reset if not specified + element.loadMoreOffset = 0; + } + if (options.currentId !== undefined) { + element.res_id = options.currentId; + } + if (options.ids !== undefined) { + element.res_ids = options.ids; + element.count = element.res_ids.length; + } + if (element.type === 'record') { + element.offset = _.indexOf(element.res_ids, element.res_id); + } + var loadOptions = _.pick(options, 'fieldNames', 'viewType'); + return this._load(element, loadOptions).then(function (result) { + return result.id; + }); + }, + /** + * Override to handle the case where we want sample data, and we are in a + * grouped kanban or list view with real groups, but all groups are empty. + * In this case, we use the result of the web_read_group rpc to tweak the + * data in the SampleServer instance of the sampleModel (so that calls to + * that server will return the same groups). + * + * @override + */ + async _rpc(params) { + const result = await this._super(...arguments); + if (this.sampleModel && params.method === 'web_read_group' && result.length) { + const sampleServer = this.sampleModel.sampleServer; + sampleServer.setExistingGroups(result.groups); + } + return result; + }, + /** + * Allows to save a value in the specialData cache associated to a given + * record and fieldName. If the value in the cache was already the given + * one, nothing is done and the method indicates it by returning false + * instead of true. + * + * @private + * @param {Object} record - an element from the localData + * @param {string} fieldName - the name of the field + * @param {*} value - the cache value to save + * @returns {boolean} false if the value was already the given one + */ + _saveSpecialDataCache: function (record, fieldName, value) { + if (_.isEqual(record._specialDataCache[fieldName], value)) { + return false; + } + record._specialDataCache[fieldName] = value; + return true; + }, + /** + * Do a /search_read to get data for a list resource. This does a + * /search_read because the data may not be static (for ex, a list view). + * + * @param {Object} list + * @returns {Promise} + */ + _searchReadUngroupedList: function (list) { + var self = this; + var fieldNames = list.getFieldNames(); + var prom; + if (list.__data) { + // the data have already been fetched (alonside the groups by the + // call to 'web_read_group'), so we can bypass the search_read + prom = Promise.resolve(list.__data); + } else { + prom = this._rpc({ + route: '/web/dataset/search_read', + model: list.model, + fields: fieldNames, + context: _.extend({}, list.getContext(), {bin_size: true}), + domain: list.domain || [], + limit: list.limit, + offset: list.loadMoreOffset + list.offset, + orderBy: list.orderedBy, + }); + } + return prom.then(function (result) { + delete list.__data; + list.count = result.length; + var ids = _.pluck(result.records, 'id'); + var data = _.map(result.records, function (record) { + var dataPoint = self._makeDataPoint({ + context: list.context, + data: record, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + modelName: list.model, + parentID: list.id, + viewType: list.viewType, + }); + + // add many2one records + self._parseServerData(fieldNames, dataPoint, dataPoint.data); + return dataPoint.id; + }); + if (list.loadMoreOffset) { + list.data = list.data.concat(data); + list.res_ids = list.res_ids.concat(ids); + } else { + list.data = data; + list.res_ids = ids; + } + self._updateParentResIDs(list); + return list; + }); + }, + /** + * Set data in range, i.e. according to the list offset and limit. + * + * @param {Object} list + */ + _setDataInRange: function (list) { + var idsInRange; + if (list.limit) { + idsInRange = list.res_ids.slice(list.offset, list.offset + list.limit); + } else { + idsInRange = list.res_ids; + } + list.data = []; + _.each(idsInRange, function (id) { + if (list._cache[id]) { + list.data.push(list._cache[id]); + } + }); + + // display newly created record in addition to the displayed records + if (list.limit) { + for (var i = list.offset + list.limit; i < list.res_ids.length; i++) { + var id = list.res_ids[i]; + var dataPointID = list._cache[id]; + if (_.findWhere(list._changes, {isNew: true, id: dataPointID})) { + list.data.push(dataPointID); + } else { + break; + } + } + } + }, + /** + * Change the offset of a record. Note that this does not reload the data. + * The offset is used to load a different record in a list of record (for + * example, a form view with a pager. Clicking on next/previous actually + * changes the offset through this method). + * + * @param {string} elementId local id for the resource + * @param {number} offset + */ + _setOffset: function (elementId, offset) { + var element = this.localData[elementId]; + element.offset = offset; + if (element.type === 'record' && element.res_ids.length) { + element.res_id = element.res_ids[offset]; + } + }, + /** + * Do a in-memory sort of a list resource data points. This method assumes + * that the list data has already been fetched, and that the changes that + * need to be sorted have already been applied. Its intended use is for + * static datasets, such as a one2many in a form view. + * + * @param {Object} list list dataPoint on which (some) changes might have + * been applied; it is a copy of an internal dataPoint, not the result of + * get + */ + _sortList: function (list) { + if (!list.static) { + // only sort x2many lists + return; + } + var self = this; + + if (list.orderedResIDs) { + var orderedResIDs = {}; + for (var k = 0; k < list.orderedResIDs.length; k++) { + orderedResIDs[list.orderedResIDs[k]] = k; + } + utils.stableSort(list.res_ids, function compareResIdIndexes (resId1, resId2) { + if (!(resId1 in orderedResIDs) && !(resId2 in orderedResIDs)) { + return 0; + } + if (!(resId1 in orderedResIDs)) { + return Infinity; + } + if (!(resId2 in orderedResIDs)) { + return -Infinity; + } + return orderedResIDs[resId1] - orderedResIDs[resId2]; + }); + } else if (list.orderedBy.length) { + // sort records according to ordered_by[0] + var compareRecords = function (resId1, resId2, level) { + if(!level) { + level = 0; + } + if(list.orderedBy.length < level + 1) { + return 0; + } + var order = list.orderedBy[level]; + var record1ID = list._cache[resId1]; + var record2ID = list._cache[resId2]; + if (!record1ID && !record2ID) { + return 0; + } + if (!record1ID) { + return Infinity; + } + if (!record2ID) { + return -Infinity; + } + var r1 = self.localData[record1ID]; + var r2 = self.localData[record2ID]; + var data1 = _.extend({}, r1.data, r1._changes); + var data2 = _.extend({}, r2.data, r2._changes); + + // Default value to sort against: the value of the field + var orderData1 = data1[order.name]; + var orderData2 = data2[order.name]; + + // If the field is a relation, sort on the display_name of those records + if (list.fields[order.name].type === 'many2one') { + orderData1 = orderData1 ? self.localData[orderData1].data.display_name : ""; + orderData2 = orderData2 ? self.localData[orderData2].data.display_name : ""; + } + if (orderData1 < orderData2) { + return order.asc ? -1 : 1; + } + if (orderData1 > orderData2) { + return order.asc ? 1 : -1; + } + return compareRecords(resId1, resId2, level + 1); + }; + utils.stableSort(list.res_ids, compareRecords); + } + this._setDataInRange(list); + }, + /** + * Updates the res_ids of the parent of a given element of type list. + * + * After some operations (e.g. loading more records, folding/unfolding a + * group), the res_ids list of an element may be updated. When this happens, + * the res_ids of its ancestors need to be updated as well. This is the + * purpose of this function. + * + * @param {Object} element + */ + _updateParentResIDs: function (element) { + var self = this; + if (element.parentID) { + var parent = this.localData[element.parentID]; + parent.res_ids = _.flatten(_.map(parent.data, function (dataPointID) { + return self.localData[dataPointID].res_ids; + })); + this._updateParentResIDs(parent); + } + }, + /** + * Helper method to create datapoints and assign them values, then link + * those datapoints into records' data. + * + * @param {Object[]} records a list of record where datapoints will be + * assigned, it assumes _applyX2ManyOperations and _sort have been + * already called on this list + * @param {string} fieldName concerned field in records + * @param {Object[]} values typically a list of values got from a rpc + */ + _updateRecordsData: function (records, fieldName, values) { + if (!records.length || !values) { + return; + } + var self = this; + var field = records[0].fields[fieldName]; + var fieldInfo = records[0].fieldsInfo[records[0].viewType][fieldName]; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; + var fields = view ? view.fields : fieldInfo.relatedFields; + var viewType = view ? view.type : fieldInfo.viewType; + + _.each(records, function (record) { + var x2mList = self.localData[record.data[fieldName]]; + x2mList.data = []; + _.each(x2mList.res_ids, function (res_id) { + var dataPoint = self._makeDataPoint({ + modelName: field.relation, + data: _.findWhere(values, {id: res_id}), + fields: fields, + fieldsInfo: fieldsInfo, + parentID: x2mList.id, + viewType: viewType, + }); + x2mList.data.push(dataPoint.id); + x2mList._cache[res_id] = dataPoint.id; + }); + }); + }, + /** + * Helper method. Recursively traverses the data, starting from the element + * record (or list), then following all relations. This is useful when one + * want to determine a property for the current record. + * + * For example, isDirty need to check all relations to find out if something + * has been modified, or not. + * + * Note that this method follows all the changes, so if a record has + * relational sub data, it will visit the new sub records and not the old + * ones. + * + * @param {Object} element a valid local resource + * @param {callback} fn a function to be called on each visited element + */ + _visitChildren: function (element, fn) { + var self = this; + fn(element); + if (element.type === 'record') { + for (var fieldName in element.data) { + var field = element.fields[fieldName]; + if (!field) { + continue; + } + if (_.contains(['one2many', 'many2one', 'many2many'], field.type)) { + var hasChange = element._changes && fieldName in element._changes; + var value = hasChange ? element._changes[fieldName] : element.data[fieldName]; + var relationalElement = this.localData[value]; + // relationalElement could be empty in the case of a many2one + if (relationalElement) { + self._visitChildren(relationalElement, fn); + } + } + } + } + if (element.type === 'list') { + element = this._applyX2ManyOperations(element); + _.each(element.data, function (elemId) { + var elem = self.localData[elemId]; + self._visitChildren(elem, fn); + }); + } + }, +}); + +return BasicModel; +}); diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js new file mode 100644 index 00000000..a238ab2e --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -0,0 +1,926 @@ +odoo.define('web.BasicRenderer', function (require) { +"use strict"; + +/** + * The BasicRenderer is an abstract class designed to share code between all + * views that uses a BasicModel. The main goal is to keep track of all field + * widgets, and properly destroy them whenever a rerender is done. The widgets + * and modifiers updates mechanism is also shared in the BasicRenderer. + */ +var AbstractRenderer = require('web.AbstractRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var dom = require('web.dom'); +const session = require('web.session'); +const utils = require('web.utils'); +var widgetRegistry = require('web.widget_registry'); + +const { WidgetAdapterMixin } = require('web.OwlCompatibility'); +const FieldWrapper = require('web.FieldWrapper'); + +var qweb = core.qweb; +const _t = core._t; + +var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, { + custom_events: { + navigation_move: '_onNavigationMove', + }, + /** + * Basic renderers implements the concept of "mode", they can either be in + * readonly mode or editable mode. + * + * @override + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.activeActions = params.activeActions; + this.viewType = params.viewType; + this.mode = params.mode || 'readonly'; + this.widgets = []; + // This attribute lets us know if there is a handle widget on a field, + // and on which field it is set. + this.handleField = null; + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + WidgetAdapterMixin.destroy.call(this); + }, + /** + * Called each time the renderer is attached into the DOM. + */ + on_attach_callback: function () { + this._isInDom = true; + // call on_attach_callback on field widgets + for (const handle in this.allFieldWidgets) { + this.allFieldWidgets[handle].forEach(widget => { + if (!utils.isComponent(widget.constructor) && widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + } + // call on_attach_callback on widgets + this.widgets.forEach(widget => { + if (widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + // call on_attach_callback on child components (including field components) + WidgetAdapterMixin.on_attach_callback.call(this); + }, + /** + * Called each time the renderer is detached from the DOM. + */ + on_detach_callback: function () { + this._isInDom = false; + // call on_detach_callback on field widgets + for (const handle in this.allFieldWidgets) { + this.allFieldWidgets[handle].forEach(widget => { + if (!utils.isComponent(widget.constructor) && widget.on_detach_callback) { + widget.on_detach_callback(); + } + }); + } + // call on_detach_callback on widgets + this.widgets.forEach(widget => { + if (widget.on_detach_callback) { + widget.on_detach_callback(); + } + }); + // call on_detach_callback on child components (including field components) + WidgetAdapterMixin.on_detach_callback.call(this); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This method has two responsabilities: find every invalid fields in the + * current view, and making sure that they are displayed as invalid, by + * toggling the o_form_invalid css class. It has to be done both on the + * widget, and on the label, if any. + * + * @param {string} recordID + * @returns {string[]} the list of invalid field names + */ + canBeSaved: function (recordID) { + var self = this; + var invalidFields = []; + _.each(this.allFieldWidgets[recordID], function (widget) { + var canBeSaved = self._canWidgetBeSaved(widget); + if (!canBeSaved) { + invalidFields.push(widget.name); + } + if (widget.el) { // widget may not be started yet + widget.$el.toggleClass('o_field_invalid', !canBeSaved); + widget.$el.attr('aria-invalid', !canBeSaved); + } + }); + return invalidFields; + }, + /** + * Calls 'commitChanges' on all field widgets, so that they can notify the + * environment with their current value (useful for widgets that can't + * detect when their value changes or that have to validate their changes + * before notifying them). + * + * @param {string} recordID + * @return {Promise} + */ + commitChanges: function (recordID) { + var defs = _.map(this.allFieldWidgets[recordID], function (widget) { + return widget.commitChanges(); + }); + return Promise.all(defs); + }, + /** + * Updates the internal state of the renderer to the new state. By default, + * this also implements the recomputation of the modifiers and their + * application to the DOM and the reset of the field widgets if needed. + * + * In case the given record is not found anymore, a whole re-rendering is + * completed (possible if a change in a record caused an onchange which + * erased the current record). + * + * We could always rerender the view from scratch, but then it would not be + * as efficient, and we might lose some local state, such as the input focus + * cursor, or the scrolling position. + * + * @param {Object} state + * @param {string} id + * @param {string[]} fields + * @param {OdooEvent} ev + * @returns {Promise<AbstractField[]>} resolved with the list of widgets + * that have been reset + */ + confirmChange: function (state, id, fields, ev) { + var self = this; + this._setState(state); + var record = this._getRecord(id); + if (!record) { + return this._render().then(_.constant([])); + } + + // reset all widgets (from the <widget> tag) if any: + _.invoke(this.widgets, 'updateState', state); + + var defs = []; + + // Reset all the field widgets that are marked as changed and the ones + // which are configured to always be reset on any change + _.each(this.allFieldWidgets[id], function (widget) { + var fieldChanged = _.contains(fields, widget.name); + if (fieldChanged || widget.resetOnAnyFieldChange) { + defs.push(widget.reset(record, ev, fieldChanged)); + } + }); + + // The modifiers update is done after widget resets as modifiers + // associated callbacks need to have all the widgets with the proper + // state before evaluation + defs.push(this._updateAllModifiers(record)); + + return Promise.all(defs).then(function () { + return _.filter(self.allFieldWidgets[id], function (widget) { + var fieldChanged = _.contains(fields, widget.name); + return fieldChanged || widget.resetOnAnyFieldChange; + }); + }); + }, + /** + * Activates the widget and move the cursor to the given offset + * + * @param {string} id + * @param {string} fieldName + * @param {integer} offset + */ + focusField: function (id, fieldName, offset) { + this.editRecord(id); + if (typeof offset === "number") { + var field = _.findWhere(this.allFieldWidgets[id], {name: fieldName}); + dom.setSelectionRange(field.getFocusableElement().get(0), {start: offset, end: offset}); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Activates the widget at the given index for the given record if possible + * or the "next" possible one. Usually, a widget can be activated if it is + * in edit mode, and if it is visible. + * + * @private + * @param {Object} record + * @param {integer} currentIndex + * @param {Object} [options={}] + * @param {integer} [options.inc=1] - the increment to use when searching for the + * "next" possible one + * @param {boolean} [options.noAutomaticCreate=false] + * @param {boolean} [options.wrap=false] if true, when we arrive at the end of the + * list of widget, we wrap around and try to activate widgets starting at + * the beginning. Otherwise, we just stop trying and return -1 + * @returns {integer} the index of the widget that was activated or -1 if + * none was possible to activate + */ + _activateFieldWidget: function (record, currentIndex, options) { + options = options || {}; + _.defaults(options, {inc: 1, wrap: false}); + currentIndex = Math.max(0,currentIndex); // do not allow negative currentIndex + + var recordWidgets = this.allFieldWidgets[record.id] || []; + for (var i = 0 ; i < recordWidgets.length ; i++) { + var activated = recordWidgets[currentIndex].activate( + { + event: options.event, + noAutomaticCreate: options.noAutomaticCreate || false + }); + if (activated) { + return currentIndex; + } + + currentIndex += options.inc; + if (currentIndex >= recordWidgets.length) { + if (options.wrap) { + currentIndex -= recordWidgets.length; + } else { + return -1; + } + } else if (currentIndex < 0) { + if (options.wrap) { + currentIndex += recordWidgets.length; + } else { + return -1; + } + } + } + return -1; + }, + /** + * This is a wrapper of the {@see _activateFieldWidget} function to select + * the next possible widget instead of the given one. + * + * @private + * @param {Object} record + * @param {integer} currentIndex + * @param {Object|undefined} options + * @return {integer} + */ + _activateNextFieldWidget: function (record, currentIndex, options) { + currentIndex = (currentIndex + 1) % (this.allFieldWidgets[record.id] || []).length; + return this._activateFieldWidget(record, currentIndex, _.extend({inc: 1}, options)); + }, + /** + * This is a wrapper of the {@see _activateFieldWidget} function to select + * the previous possible widget instead of the given one. + * + * @private + * @param {Object} record + * @param {integer} currentIndex + * @return {integer} + */ + _activatePreviousFieldWidget: function (record, currentIndex) { + currentIndex = currentIndex ? (currentIndex - 1) : ((this.allFieldWidgets[record.id] || []).length - 1); + return this._activateFieldWidget(record, currentIndex, {inc:-1}); + }, + /** + * Add a tooltip on a $node, depending on a field description + * + * @param {FieldWidget} widget + * @param {$node} $node + */ + _addFieldTooltip: function (widget, $node) { + // optional argument $node, the jQuery element on which the tooltip + // should be attached if not given, the tooltip is attached on the + // widget's $el + $node = $node.length ? $node : widget.$el; + $node.tooltip(this._getTooltipOptions(widget)); + }, + /** + * Does the necessary DOM updates to match the given modifiers data. The + * modifiers data is supposed to contain the properly evaluated modifiers + * associated to the given records and elements. + * + * @param {Object} modifiersData + * @param {Object} record + * @param {Object} [element] - do the update only on this element if given + */ + _applyModifiers: function (modifiersData, record, element) { + var self = this; + var modifiers = modifiersData.evaluatedModifiers[record.id] || {}; + + if (element) { + _apply(element); + } else { + // Clone is necessary as the list might change during _.each + _.each(_.clone(modifiersData.elementsByRecord[record.id]), _apply); + } + + function _apply(element) { + // If the view is in edit mode and that a widget have to switch + // its "readonly" state, we have to re-render it completely + if ('readonly' in modifiers && element.widget) { + var mode = modifiers.readonly ? 'readonly' : modifiersData.baseModeByRecord[record.id]; + if (mode !== element.widget.mode) { + self._rerenderFieldWidget(element.widget, record, { + keepBaseMode: true, + mode: mode, + }); + return; // Rerendering already applied the modifiers, no need to go further + } + } + + // Toggle modifiers CSS classes if necessary + element.$el.toggleClass("o_invisible_modifier", !!modifiers.invisible); + element.$el.toggleClass("o_readonly_modifier", !!modifiers.readonly); + element.$el.toggleClass("o_required_modifier", !!modifiers.required); + + if (element.widget && element.widget.updateModifiersValue) { + element.widget.updateModifiersValue(modifiers); + } + + // Call associated callback + if (element.callback) { + element.callback(element, modifiers, record); + } + } + }, + /** + * Determines if a given field widget value can be saved. For this to be + * true, the widget must be valid (properly parsed value) and have a value + * if the associated view field is required. + * + * @private + * @param {AbstractField} widget + * @returns {boolean|Promise<boolean>} @see AbstractField.isValid + */ + _canWidgetBeSaved: function (widget) { + var modifiers = this._getEvaluatedModifiers(widget.__node, widget.record); + return widget.isValid() && (widget.isSet() || !modifiers.required); + }, + /** + * Destroys a given widget associated to the given record and removes it + * from internal referencing. + * + * @private + * @param {string} recordID id of the local resource + * @param {AbstractField} widget + * @returns {integer} the index of the removed widget + */ + _destroyFieldWidget: function (recordID, widget) { + var recordWidgets = this.allFieldWidgets[recordID]; + var index = recordWidgets.indexOf(widget); + if (index >= 0) { + recordWidgets.splice(index, 1); + } + this._unregisterModifiersElement(widget.__node, recordID, widget); + widget.destroy(); + return index; + }, + /** + * Searches for the last evaluation of the modifiers associated to the given + * data (modifiers evaluation are supposed to always be up-to-date as soon + * as possible). + * + * @private + * @param {Object} node + * @param {Object} record + * @returns {Object} the evaluated modifiers associated to the given node + * and record (not recomputed by the call) + */ + _getEvaluatedModifiers: function (node, record) { + var element = this._getModifiersData(node); + if (!element) { + return {}; + } + return element.evaluatedModifiers[record.id] || {}; + }, + /** + * Searches through the registered modifiers data for the one which is + * related to the given node. + * + * @private + * @param {Object} node + * @returns {Object|undefined} related modifiers data if any + * undefined otherwise + */ + _getModifiersData: function (node) { + return _.findWhere(this.allModifiersData, {node: node}); + }, + /** + * This function is meant to be overridden in renderers. It takes a dataPoint + * id (for a dataPoint of type record), and should return the corresponding + * dataPoint. + * + * @abstract + * @private + * @param {string} [recordId] + * @returns {Object|null} + */ + _getRecord: function (recordId) { + return null; + }, + /** + * Get the options for the tooltip. This allow to change this options in another module. + * @param widget + * @return {{}} + * @private + */ + _getTooltipOptions: function (widget) { + return { + title: function () { + let help = widget.attrs.help || widget.field.help || ''; + if (session.display_switch_company_menu && widget.field.company_dependent) { + help += (help ? '\n\n' : '') + _t('Values set here are company-specific.'); + } + const debug = config.isDebug(); + if (help || debug) { + return qweb.render('WidgetLabel.tooltip', { debug, help, widget }); + } + } + }; + }, + /** + * @private + * @param {jQueryElement} $el + * @param {Object} node + */ + _handleAttributes: function ($el, node) { + if ($el.is('button')) { + return; + } + if (node.attrs.class) { + $el.addClass(node.attrs.class); + } + if (node.attrs.style) { + $el.attr('style', node.attrs.style); + } + if (node.attrs.placeholder) { + $el.attr('placeholder', node.attrs.placeholder); + } + }, + /** + * Used by list and kanban renderers to determine whether or not to display + * the no content helper (if there is no data in the state to display) + * + * @private + * @returns {boolean} + */ + _hasContent: function () { + return this.state.count !== 0 && (!('isSample' in this.state) || !this.state.isSample); + }, + /** + * Force the resequencing of the records after moving one of them to a given + * index. + * + * @private + * @param {string} recordId datapoint id of the moved record + * @param {integer} toIndex new index of the moved record + */ + _moveRecord: function (recordId, toIndex) { + var self = this; + var records = this.state.data; + var record = _.findWhere(records, {id: recordId}); + var fromIndex = records.indexOf(record); + var lowerIndex = Math.min(fromIndex, toIndex); + var upperIndex = Math.max(fromIndex, toIndex) + 1; + var order = _.findWhere(this.state.orderedBy, {name: this.handleField}); + var asc = !order || order.asc; + var reorderAll = false; + var sequence = (asc ? -1 : 1) * Infinity; + + // determine if we need to reorder all records + _.each(records, function (record, index) { + if (((index < lowerIndex || index >= upperIndex) && + ((asc && sequence >= record.data[self.handleField]) || + (!asc && sequence <= record.data[self.handleField]))) || + (index >= lowerIndex && index < upperIndex && sequence === record.data[self.handleField])) { + reorderAll = true; + } + sequence = record.data[self.handleField]; + }); + + if (reorderAll) { + records = _.without(records, record); + records.splice(toIndex, 0, record); + } else { + records = records.slice(lowerIndex, upperIndex); + records = _.without(records, record); + if (fromIndex > toIndex) { + records.unshift(record); + } else { + records.push(record); + } + } + + var sequences = _.pluck(_.pluck(records, 'data'), this.handleField); + var recordIds = _.pluck(records, 'id'); + if (!asc) { + recordIds.reverse(); + } + + this.trigger_up('resequence_records', { + handleField: this.handleField, + offset: _.min(sequences), + recordIds: recordIds, + }); + }, + /** + * This function is called each time a field widget is created, when it is + * ready (after its willStart and Start methods are complete). This is the + * place where work having to do with $el should be done. + * + * @private + * @param {Widget} widget the field widget instance + * @param {Object} node the attrs coming from the arch + */ + _postProcessField: function (widget, node) { + this._handleAttributes(widget.$el, node); + }, + /** + * Registers or updates the modifiers data associated to the given node. + * This method is quiet complex as it handles all the needs of the basic + * renderers: + * + * - On first registration, the modifiers are evaluated thanks to the given + * record. This allows nodes that will produce an AbstractField instance + * to have their modifiers registered before this field creation as we + * need the readonly modifier to be able to instantiate the AbstractField. + * + * - On additional registrations, if the node was already registered but the + * record is different, we evaluate the modifiers for this record and + * saves them in the same object (without reparsing the modifiers). + * + * - On additional registrations, the modifiers are not reparsed (or + * reevaluated for an already seen record) but the given widget or DOM + * element is associated to the node modifiers. + * + * - The new elements are immediately adapted to match the modifiers and the + * given associated callback is called even if there is no modifiers on + * the node (@see _applyModifiers). This is indeed necessary as the + * callback is a description of what to do when a modifier changes. Even + * if there is no modifiers, this action must be performed on first + * rendering to avoid code duplication. If there is no modifiers, they + * will however not be registered for modifiers updates. + * + * - When a new element is given, it does not replace the old one, it is + * added as an additional element. This is indeed useful for nodes that + * will produce multiple DOM (as a list cell and its internal widget or + * a form field and its associated label). + * (@see _unregisterModifiersElement for removing an associated element.) + * + * Note: also on view rerendering, all the modifiers are forgotten so that + * the renderer only keeps the ones associated to the current DOM state. + * + * @private + * @param {Object} node + * @param {Object} record + * @param {jQuery|AbstractField} [element] + * @param {Object} [options] + * @param {Object} [options.callback] the callback to call on registration + * and on modifiers updates + * @param {boolean} [options.keepBaseMode=false] this function registers the + * 'baseMode' of the node on a per record basis; + * this is a field widget specific settings which + * represents the generic mode of the widget, regardless of its modifiers + * (the interesting case is the list view: all widgets are supposed to be + * in the baseMode 'readonly', except the ones that are in the line that + * is currently being edited). + * With option 'keepBaseMode' set to true, the baseMode of the record's + * node isn't overridden (this is particularily useful when a field widget + * is re-rendered because its readonly modifier changed, as in this case, + * we don't want to change its base mode). + * @param {string} [options.mode] the 'baseMode' of the record's node is set to this + * value (if not given, it is set to this.mode, the mode of the renderer) + * @returns {Object} for code efficiency, returns the last evaluated + * modifiers for the given node and record. + * @throws {Error} if one of the modifier domains is not valid + */ + _registerModifiers: function (node, record, element, options) { + options = options || {}; + // Check if we already registered the modifiers for the given node + // If yes, this is simply an update of the related element + // If not, check the modifiers to see if it needs registration + var modifiersData = this._getModifiersData(node); + if (!modifiersData) { + var modifiers = node.attrs.modifiers || {}; + modifiersData = { + node: node, + modifiers: modifiers, + evaluatedModifiers: {}, + elementsByRecord: {}, + baseModeByRecord : {}, + }; + if (!_.isEmpty(modifiers)) { // Register only if modifiers might change (TODO condition might be improved here) + this.allModifiersData.push(modifiersData); + } + } + + // Compute the record's base mode + if (!modifiersData.baseModeByRecord[record.id] || !options.keepBaseMode) { + modifiersData.baseModeByRecord[record.id] = options.mode || this.mode; + } + + // Evaluate if necessary + if (!modifiersData.evaluatedModifiers[record.id]) { + try { + modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers); + } catch (e) { + throw new Error(_.str.sprintf( + "While parsing modifiers for %s%s: %s", + node.tag, node.tag === 'field' ? ' ' + node.attrs.name : '', + e.message + )); + } + } + + // Element might not be given yet (a second call to the function can + // update the registration with the element) + if (element) { + var newElement = {}; + if (element instanceof jQuery) { + newElement.$el = element; + } else { + newElement.widget = element; + newElement.$el = element.$el; + } + if (options && options.callback) { + newElement.callback = options.callback; + } + + if (!modifiersData.elementsByRecord[record.id]) { + modifiersData.elementsByRecord[record.id] = []; + } + modifiersData.elementsByRecord[record.id].push(newElement); + + this._applyModifiers(modifiersData, record, newElement); + } + + return modifiersData.evaluatedModifiers[record.id]; + }, + /** + * @override + */ + async _render() { + const oldAllFieldWidgets = this.allFieldWidgets; + this.allFieldWidgets = {}; // TODO maybe merging allFieldWidgets and allModifiersData into "nodesData" in some way could be great + this.allModifiersData = []; + const oldWidgets = this.widgets; + this.widgets = []; + + await this._super(...arguments); + + for (const id in oldAllFieldWidgets) { + for (const widget of oldAllFieldWidgets[id]) { + widget.destroy(); + } + } + for (const widget of oldWidgets) { + widget.destroy(); + } + if (this._isInDom) { + for (const handle in this.allFieldWidgets) { + this.allFieldWidgets[handle].forEach(widget => { + if (!utils.isComponent(widget.constructor) && widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + } + this.widgets.forEach(widget => { + if (widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + // call on_attach_callback on child components (including field components) + WidgetAdapterMixin.on_attach_callback.call(this); + } + }, + /** + * Instantiates the appropriate AbstractField specialization for the given + * node and prepares its rendering and addition to the DOM. Indeed, the + * rendering of the widget will be started and the associated promise will + * be added to the 'defs' attribute. This is supposed to be created and + * deleted by the calling code if necessary. + * + * Note: we always return a $el. If the field widget is asynchronous, this + * $el will be replaced by the real $el, whenever the widget is ready (start + * method is done). This means that this is not the correct place to make + * changes on the widget $el. For this, @see _postProcessField method + * + * @private + * @param {Object} node + * @param {Object} record + * @param {Object} [options] passed to @_registerModifiers + * @param {string} [options.mode] either 'edit' or 'readonly' (defaults to + * this.mode, the mode of the renderer) + * @returns {jQueryElement} + */ + _renderFieldWidget: function (node, record, options) { + options = options || {}; + var fieldName = node.attrs.name; + // Register the node-associated modifiers + var mode = options.mode || this.mode; + var modifiers = this._registerModifiers(node, record, null, options); + // Initialize and register the widget + // Readonly status is known as the modifiers have just been registered + var Widget = record.fieldsInfo[this.viewType][fieldName].Widget; + const legacy = !(Widget.prototype instanceof owl.Component); + const widgetOptions = { + mode: modifiers.readonly ? 'readonly' : mode, + viewType: this.viewType, + }; + let widget; + if (legacy) { + widget = new Widget(this, fieldName, record, widgetOptions); + } else { + widget = new FieldWrapper(this, Widget, { + fieldName, + record, + options: widgetOptions, + }); + } + + // Register the widget so that it can easily be found again + if (this.allFieldWidgets[record.id] === undefined) { + this.allFieldWidgets[record.id] = []; + } + this.allFieldWidgets[record.id].push(widget); + + widget.__node = node; // TODO get rid of this if possible one day + + // Prepare widget rendering and save the related promise + var $el = $('<div>'); + let def; + if (legacy) { + def = widget._widgetRenderAndInsert(function () {}); + } else { + def = widget.mount(document.createDocumentFragment()); + } + + this.defs.push(def); + + // Update the modifiers registration by associating the widget and by + // giving the modifiers options now (as the potential callback is + // associated to new widget) + var self = this; + def.then(function () { + // when the caller of renderFieldWidget uses something like + // this.renderFieldWidget(...).addClass(...), the class is added on + // the temporary div and not on the actual element that will be + // rendered. As we do not return a promise and some callers cannot + // wait for this.defs, we copy those classnames to the final element. + widget.$el.addClass($el.attr('class')); + + $el.replaceWith(widget.$el); + self._registerModifiers(node, record, widget, { + callback: function (element, modifiers, record) { + element.$el.toggleClass('o_field_empty', !!( + record.data.id && + (modifiers.readonly || mode === 'readonly') && + element.widget.isEmpty() + )); + }, + keepBaseMode: !!options.keepBaseMode, + mode: mode, + }); + self._postProcessField(widget, node); + }); + + return $el; + }, + /** + * Instantiate custom widgets + * + * @private + * @param {Object} record + * @param {Object} node + * @returns {jQueryElement} + */ + _renderWidget: function (record, node) { + var Widget = widgetRegistry.get(node.attrs.name); + var widget = new Widget(this, record, node); + + this.widgets.push(widget); + + // Prepare widget rendering and save the related promise + var def = widget._widgetRenderAndInsert(function () {}); + this.defs.push(def); + var $el = $('<div>'); + + var self = this; + def.then(function () { + self._handleAttributes(widget.$el, node); + self._registerModifiers(node, record, widget); + widget.$el.addClass('o_widget'); + $el.replaceWith(widget.$el); + }); + + return $el; + }, + /** + * Rerenders a given widget and make sure the associated data which + * referenced the old one is updated. + * + * @private + * @param {Widget} widget + * @param {Object} record + * @param {Object} [options] options passed to @_renderFieldWidget + */ + _rerenderFieldWidget: function (widget, record, options) { + // Render the new field widget + var $el = this._renderFieldWidget(widget.__node, record, options); + // get the new widget that has just been pushed in allFieldWidgets + const recordWidgets = this.allFieldWidgets[record.id]; + const newWidget = recordWidgets[recordWidgets.length - 1]; + const def = this.defs[this.defs.length - 1]; // this is the widget's def, resolved when it is ready + const $div = $('<div>'); + $div.append($el); // $el will be replaced when widget is ready (see _renderFieldWidget) + def.then(() => { + widget.$el.replaceWith($div.children()); + + // Destroy the old widget and position the new one at the old one's + // (it has been temporarily inserted at the end of the list) + recordWidgets.splice(recordWidgets.indexOf(newWidget), 1); + var oldIndex = this._destroyFieldWidget(record.id, widget); + recordWidgets.splice(oldIndex, 0, newWidget); + + // Mount new widget if necessary (mainly for Owl components) + if (this._isInDom && newWidget.on_attach_callback) { + newWidget.on_attach_callback(); + } + }); + }, + /** + * Unregisters an element of the modifiers data associated to the given + * node and record. + * + * @param {Object} node + * @param {string} recordID id of the local resource + * @param {jQuery|AbstractField} element + */ + _unregisterModifiersElement: function (node, recordID, element) { + var modifiersData = this._getModifiersData(node); + if (modifiersData) { + var elements = modifiersData.elementsByRecord[recordID]; + var index = _.findIndex(elements, function (oldElement) { + return oldElement.widget === element + || oldElement.$el[0] === element[0]; + }); + if (index >= 0) { + elements.splice(index, 1); + } + } + }, + /** + * Does two actions, for each registered modifiers: + * 1) Recomputes the modifiers associated to the given record and saves them + * (as boolean values) in the appropriate modifiers data. + * 2) Updates the rendering of the view elements associated to the given + * record to match the new modifiers. + * + * @see _applyModifiers + * + * @private + * @param {Object} record + * @returns {Promise} resolved once finished + */ + _updateAllModifiers: function (record) { + var self = this; + + var defs = []; + this.defs = defs; // Potentially filled by widget rerendering + _.each(this.allModifiersData, function (modifiersData) { + // `allModifiersData` might contain modifiers registered for other + // records than the given record (e.g. <groupby> in list) + if (record.id in modifiersData.evaluatedModifiers) { + modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers); + self._applyModifiers(modifiersData, record); + } + }); + delete this.defs; + + return Promise.all(defs); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When someone presses the TAB/UP/DOWN/... key in a widget, it is nice to + * be able to navigate in the view (default browser behaviors are disabled + * by Odoo). + * + * @abstract + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) {}, +}); + +return BasicRenderer; +}); diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js new file mode 100644 index 00000000..5e3938fb --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -0,0 +1,454 @@ +odoo.define('web.BasicView', function (require) { +"use strict"; + +/** + * The BasicView is an abstract class designed to share code between views that + * want to use a basicModel. As of now, it is the form view, the list view and + * the kanban view. + * + * The main focus of this class is to process the arch and extract field + * attributes, as well as some other useful informations. + */ + +var AbstractView = require('web.AbstractView'); +var BasicController = require('web.BasicController'); +var BasicModel = require('web.BasicModel'); +var config = require('web.config'); +var fieldRegistry = require('web.field_registry'); +var fieldRegistryOwl = require('web.field_registry_owl'); +var pyUtils = require('web.py_utils'); +var utils = require('web.utils'); + +var BasicView = AbstractView.extend({ + config: _.extend({}, AbstractView.prototype.config, { + Model: BasicModel, + Controller: BasicController, + }), + viewType: undefined, + /** + * process the fields_view to find all fields appearing in the views. + * list those fields' name in this.fields_name, which will be the list + * of fields read when data is fetched. + * this.fields is the list of all field's description (the result of + * the fields_get), where the fields appearing in the fields_view are + * augmented with their attrs and some flags if they require a + * particular handling. + * + * @param {Object} viewInfo + * @param {Object} params + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + + this.fieldsInfo = {}; + this.fieldsInfo[this.viewType] = this.fieldsView.fieldsInfo[this.viewType]; + + this.rendererParams.viewType = this.viewType; + + this.controllerParams.confirmOnDelete = true; + this.controllerParams.archiveEnabled = 'active' in this.fields || 'x_active' in this.fields; + this.controllerParams.hasButtons = + 'action_buttons' in params ? params.action_buttons : true; + this.controllerParams.viewId = viewInfo.view_id; + + this.loadParams.fieldsInfo = this.fieldsInfo; + this.loadParams.fields = this.fields; + this.loadParams.limit = parseInt(this.arch.attrs.limit, 10) || params.limit; + this.loadParams.parentID = params.parentID; + this.loadParams.viewType = this.viewType; + this.recordID = params.recordID; + + this.model = params.model; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the AbstractField specialization that should be used for the + * given field informations. If there is no mentioned specific widget to + * use, determines one according the field type. + * + * @private + * @param {string} viewType + * @param {Object} field + * @param {Object} attrs + * @returns {function|null} AbstractField specialization Class + */ + _getFieldWidgetClass: function (viewType, field, attrs) { + var FieldWidget; + if (attrs.widget) { + FieldWidget = fieldRegistryOwl.getAny([viewType + "." + attrs.widget, attrs.widget]) || + fieldRegistry.getAny([viewType + "." + attrs.widget, attrs.widget]); + if (!FieldWidget) { + console.warn("Missing widget: ", attrs.widget, " for field", attrs.name, "of type", field.type); + } + } else if (viewType === 'kanban' && field.type === 'many2many') { + // we want to display the widget many2manytags in kanban even if it + // is not specified in the view + FieldWidget = fieldRegistry.get('kanban.many2many_tags'); + } + return FieldWidget || + fieldRegistryOwl.getAny([viewType + "." + field.type, field.type, "abstract"]) || + fieldRegistry.getAny([viewType + "." + field.type, field.type, "abstract"]); + }, + /** + * In some cases, we already have a preloaded record + * + * @override + * @private + * @returns {Promise} + */ + _loadData: async function (model) { + if (this.recordID) { + // Add the fieldsInfo of the current view to the given recordID, + // as it will be shared between two views, and it must be able to + // handle changes on fields that are only on this view. + await model.addFieldsInfo(this.recordID, { + fields: this.fields, + fieldInfo: this.fieldsInfo[this.viewType], + viewType: this.viewType, + }); + + var record = model.get(this.recordID); + var viewType = this.viewType; + var viewFields = Object.keys(record.fieldsInfo[viewType]); + var fieldNames = _.difference(viewFields, Object.keys(record.data)); + var fieldsInfo = record.fieldsInfo[viewType]; + + // Suppose that in a form view, there is an x2many list view with + // a field F, and that F is also displayed in the x2many form view. + // In this case, F is represented in record.data (as it is known by + // the x2many list view), but the loaded information may not suffice + // in the form view (e.g. if field is a many2many list in the form + // view, or if it is displayed by a widget requiring specialData). + // So when this happens, F is added to the list of fieldNames to fetch. + _.each(viewFields, (name) => { + if (!_.contains(fieldNames, name)) { + var fieldType = record.fields[name].type; + var fieldInfo = fieldsInfo[name]; + + // SpecialData case: field requires specialData that haven't + // been fetched yet. + if (fieldInfo.Widget) { + var requiresSpecialData = fieldInfo.Widget.prototype.specialData; + if (requiresSpecialData && !(name in record.specialData)) { + fieldNames.push(name); + return; + } + } + + // X2Many case: field is an x2many displayed as a list or + // kanban view, but the related fields haven't been loaded yet. + if ((fieldType === 'one2many' || fieldType === 'many2many')) { + if (!('fieldsInfo' in record.data[name])) { + fieldNames.push(name); + } else { + var x2mFieldInfo = record.fieldsInfo[this.viewType][name]; + var viewType = x2mFieldInfo.viewType || x2mFieldInfo.mode; + var knownFields = Object.keys(record.data[name].fieldsInfo[record.data[name].viewType] || {}); + var newFields = Object.keys(record.data[name].fieldsInfo[viewType] || {}); + if (_.difference(newFields, knownFields).length) { + fieldNames.push(name); + } + + if (record.data[name].viewType === 'default') { + // Use case: x2many (tags) in x2many list views + // When opening the x2many record form view, the + // x2many will be reloaded but it may not have + // the same fields (ex: tags in list and list in + // form) so we need to merge the fieldsInfo to + // avoid losing the initial fields (display_name) + var fieldViews = fieldInfo.views || fieldInfo.fieldsInfo || {}; + var defaultFieldInfo = record.data[name].fieldsInfo.default; + _.each(fieldViews, function (fieldView) { + _.each(fieldView.fieldsInfo, function (x2mFieldInfo) { + _.defaults(x2mFieldInfo, defaultFieldInfo); + }); + }); + } + } + } + // Many2one: context is not the same between the different views + // this means the result of a name_get could differ + if (fieldType === 'many2one') { + if (JSON.stringify(record.data[name].context) !== + JSON.stringify(fieldInfo.context)) { + fieldNames.push(name); + } + } + } + }); + + var def; + if (fieldNames.length) { + if (model.isNew(record.id)) { + def = model.generateDefaultValues(record.id, { + fieldNames: fieldNames, + viewType: viewType, + }); + } else { + def = model.reload(record.id, { + fieldNames: fieldNames, + keepChanges: true, + viewType: viewType, + }); + } + } + return Promise.resolve(def).then(function () { + const handle = record.id; + return { state: model.get(handle), handle }; + }); + } + return this._super.apply(this, arguments); + }, + /** + * Traverses the arch and calls '_processNode' on each of its nodes. + * + * @private + * @param {Object} arch a parsed arch + * @param {Object} fv the fieldsView Object, in which _processNode can + * access and add information (like the fields' attributes in the arch) + */ + _processArch: function (arch, fv) { + var self = this; + utils.traverse(arch, function (node) { + return self._processNode(node, fv); + }); + }, + /** + * Processes a field node, in particular, put a flag on the field to give + * special directives to the BasicModel. + * + * @private + * @param {string} viewType + * @param {Object} field - the field properties + * @param {Object} attrs - the field attributes (from the xml) + * @returns {Object} attrs + */ + _processField: function (viewType, field, attrs) { + var self = this; + attrs.Widget = this._getFieldWidgetClass(viewType, field, attrs); + + // process decoration attributes + _.each(attrs, function (value, key) { + if (key.startsWith('decoration-')) { + attrs.decorations = attrs.decorations || []; + attrs.decorations.push({ + name: key, + expression: pyUtils._getPyJSAST(value), + }); + } + }); + + if (!_.isObject(attrs.options)) { // parent arch could have already been processed (TODO this should not happen) + attrs.options = attrs.options ? pyUtils.py_eval(attrs.options) : {}; + } + + if (attrs.on_change && attrs.on_change !== "0" && !field.onChange) { + field.onChange = "1"; + } + + // the relational data of invisible relational fields should not be + // fetched (e.g. name_gets of invisible many2ones), at least those that + // are always invisible. + // the invisible attribute of a field is supposed to be static ("1" in + // general), but not totally as it may use keys of the context + // ("context.get('some_key')"). It is evaluated server-side, and the + // result is put inside the modifiers as a value of the '(column_)invisible' + // key, and the raw value is left in the invisible attribute (it is used + // in debug mode for informational purposes). + // this should change, for instance the server might set the evaluated + // value in invisible, which could then be seen as static by the client, + // and add another key in debug mode containing the raw value. + // for now, we look inside the modifiers and consider the value only if + // it is static (=== true), + if (attrs.modifiers.invisible === true || attrs.modifiers.column_invisible === true) { + attrs.__no_fetch = true; + } + + if (!_.isEmpty(field.views)) { + // process the inner fields_view as well to find the fields they use. + // register those fields' description directly on the view. + // for those inner views, the list of all fields isn't necessary, so + // basically the field_names will be the keys of the fields obj. + // don't use _ to iterate on fields in case there is a 'length' field, + // as _ doesn't behave correctly when there is a length key in the object + attrs.views = {}; + _.each(field.views, function (innerFieldsView, viewType) { + viewType = viewType === 'tree' ? 'list' : viewType; + attrs.views[viewType] = self._processFieldsView(innerFieldsView, viewType); + }); + } + + attrs.views = attrs.views || {}; + + // Keep compatibility with 'tree' syntax + attrs.mode = attrs.mode === 'tree' ? 'list' : attrs.mode; + if (!attrs.views.list && attrs.views.tree) { + attrs.views.list = attrs.views.tree; + } + + if (field.type === 'one2many' || field.type === 'many2many') { + if (attrs.Widget.prototype.useSubview) { + var mode = attrs.mode; + if (!mode) { + if (attrs.views.list && !attrs.views.kanban) { + mode = 'list'; + } else if (!attrs.views.list && attrs.views.kanban) { + mode = 'kanban'; + } else { + mode = 'list,kanban'; + } + } + if (mode.indexOf(',') !== -1) { + mode = config.device.isMobile ? 'kanban' : 'list'; + } + attrs.mode = mode; + if (mode in attrs.views) { + var view = attrs.views[mode]; + this._processSubViewAttrs(view, attrs); + } + } + if (attrs.Widget.prototype.fieldsToFetch) { + attrs.viewType = 'default'; + attrs.relatedFields = _.extend({}, attrs.Widget.prototype.fieldsToFetch); + attrs.fieldsInfo = { + default: _.mapObject(attrs.Widget.prototype.fieldsToFetch, function () { + return {}; + }), + }; + if (attrs.options.color_field) { + // used by m2m tags + attrs.relatedFields[attrs.options.color_field] = { type: 'integer' }; + attrs.fieldsInfo.default[attrs.options.color_field] = {}; + } + } + } + + if (attrs.Widget.prototype.fieldDependencies) { + attrs.fieldDependencies = attrs.Widget.prototype.fieldDependencies; + } + + return attrs; + }, + /** + * Overrides to process the fields, and generate fieldsInfo which contains + * the description of the fields in view, with their attrs in the arch. + * + * @override + * @private + * @param {Object} fieldsView + * @param {string} fieldsView.arch + * @param {Object} fieldsView.fields + * @param {string} [viewType] by default, this.viewType + * @returns {Object} the processed fieldsView with extra key 'fieldsInfo' + */ + _processFieldsView: function (fieldsView, viewType) { + var fv = this._super.apply(this, arguments); + + viewType = viewType || this.viewType; + fv.type = viewType; + fv.fieldsInfo = Object.create(null); + fv.fieldsInfo[viewType] = Object.create(null); + + this._processArch(fv.arch, fv); + + return fv; + }, + /** + * Processes a node of the arch (mainly nodes with tagname 'field'). Can + * be overridden to handle other tagnames. + * + * @private + * @param {Object} node + * @param {Object} fv the fieldsView + * @param {Object} fv.fieldsInfo + * @param {Object} fv.fieldsInfo[viewType] fieldsInfo of the current viewType + * @param {Object} fv.viewFields the result of a fields_get extend with the + * fields returned with the fields_view_get for the current viewType + * @param {string} fv.viewType + * @returns {boolean} false iff subnodes must not be visited. + */ + _processNode: function (node, fv) { + if (typeof node === 'string') { + return false; + } + if (!_.isObject(node.attrs.modifiers)) { + node.attrs.modifiers = node.attrs.modifiers ? JSON.parse(node.attrs.modifiers) : {}; + } + if (!_.isObject(node.attrs.options) && node.tag === 'button') { + node.attrs.options = node.attrs.options ? JSON.parse(node.attrs.options) : {}; + } + if (node.tag === 'field') { + var viewType = fv.type; + var fieldsInfo = fv.fieldsInfo[viewType]; + var fields = fv.viewFields; + fieldsInfo[node.attrs.name] = this._processField(viewType, + fields[node.attrs.name], node.attrs ? _.clone(node.attrs) : {}); + + if (fieldsInfo[node.attrs.name].fieldDependencies) { + var deps = fieldsInfo[node.attrs.name].fieldDependencies; + for (var dependency_name in deps) { + var dependency_dict = {name: dependency_name, type: deps[dependency_name].type}; + if (!(dependency_name in fieldsInfo)) { + fieldsInfo[dependency_name] = _.extend({}, dependency_dict, { + options: deps[dependency_name].options || {}, + }); + } + if (!(dependency_name in fields)) { + fields[dependency_name] = dependency_dict; + } + + if (fv.fields && !(dependency_name in fv.fields)) { + fv.fields[dependency_name] = dependency_dict; + } + } + } + return false; + } + return node.tag !== 'arch'; + }, + /** + * Processes in place the subview attributes (in particular, + * `default_order``and `column_invisible`). + * + * @private + * @param {Object} view - the field subview + * @param {Object} attrs - the field attributes (from the xml) + */ + _processSubViewAttrs: function (view, attrs) { + var defaultOrder = view.arch.attrs.default_order; + if (defaultOrder) { + // process the default_order, which is like 'name,id desc' + // but we need it like [{name: 'name', asc: true}, {name: 'id', asc: false}] + attrs.orderedBy = _.map(defaultOrder.split(','), function (order) { + order = order.trim().split(' '); + return {name: order[0], asc: order[1] !== 'desc'}; + }); + } else { + // if there is a field with widget `handle`, the x2many + // needs to be ordered by this field to correctly display + // the records + var handleField = _.find(view.arch.children, function (child) { + return child.attrs && child.attrs.widget === 'handle'; + }); + if (handleField) { + attrs.orderedBy = [{name: handleField.attrs.name, asc: true}]; + } + } + + attrs.columnInvisibleFields = {}; + _.each(view.arch.children, function (child) { + if (child.attrs && child.attrs.modifiers) { + attrs.columnInvisibleFields[child.attrs.name] = + child.attrs.modifiers.column_invisible || false; + } + }); + }, +}); + +return BasicView; + +}); diff --git a/addons/web/static/src/js/views/basic/widget_registry.js b/addons/web/static/src/js/views/basic/widget_registry.js new file mode 100644 index 00000000..470127bd --- /dev/null +++ b/addons/web/static/src/js/views/basic/widget_registry.js @@ -0,0 +1,27 @@ +odoo.define('web.widget_registry', function (require) { + "use strict"; + + // This registry is supposed to contain all custom widgets that will be + // available in the basic views, with the tag <widget/>. There are + // currently no such widget in the web client, but the functionality is + // certainly useful to be able to cleanly add custom behaviour in basic + // views (and most notably, the form view) + // + // The way custom widgets work is that they register themselves to this + // registry: + // + // widgetRegistry.add('some_name', MyWidget); + // + // Then, they are available with the <widget/> tag (in the arch): + // + // <widget name="some_name"/> + // + // Widgets will be then properly instantiated, rendered and destroyed at the + // appropriate time, with the current state in second argument. + // + // For more examples, look at the tests (grep '<widget' in the test folder) + + var Registry = require('web.Registry'); + + return new Registry(); +}); |
