diff options
| author | Aaron Bohy <aab@odoo.com> | 2017-04-12 10:44:06 +0200 |
|---|---|---|
| committer | Aaron Bohy <aab@odoo.com> | 2017-04-12 10:44:06 +0200 |
| commit | 3638cade098fe8531b1f34a0481aadd090f1ea09 (patch) | |
| tree | 35e9bc3bcf8be0b52b6ddb929fa7a9d2894c5da4 /addons/web/static/src/js/views/basic/basic_model.js | |
| parent | f1168d06c0415092c4adc9c2c6917ac5e67a1420 (diff) | |
| parent | e2e0353ce576f3c38814ae39ca4ab18ff0b6785f (diff) | |
[MERGE] web,*: redesign all JS views
This branch introduces a full redesign of all JS views. We started
basically from scratch. The goal was to unify all the various views
under a common framework, to make them testable, to make then usable
in different conditions (in studio, or in the frontend), and to make
our lives easier.
Some important points are:
- we have a brand new testing framework (still QUnit based)
- kanban view (formerly in web_kanban) moved to the web addon
- calendar view (formerly in web_calendar) moved to web as well
- tree view was removed
- each view is now separated into 3 parts: the renderer, the model,
the controller (so, actually a kind of MVC)
- we introduced new coding guidelines (camelCase, 80 chars width, ...)
- all new code should be documented
We hope that this code is the start of a new era for the Odoo web
client, we want to have a high quality codebase, well documented, well
tested, well designed.
Work done by the framework team: mostly aab, ged, chm, dmo, qsm, mge,
fwi, adr
Diffstat (limited to 'addons/web/static/src/js/views/basic/basic_model.js')
| -rw-r--r-- | addons/web/static/src/js/views/basic/basic_model.js | 2596 |
1 files changed, 2596 insertions, 0 deletions
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 00000000000..e5e7da7867b --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -0,0 +1,2596 @@ +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}, + * groupedBy: {string[]}, + * id: {integer}, + * isOpen: {boolean}, + * limit: {integer}, + * model: {string, + * offset: {integer}, + * openGroupByDefault: {boolean}, + * orderedBy: {string[]}, + * 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, 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 Domain = require('web.Domain'); +var fieldUtils = require('web.field_utils'); +var session = require('web.session'); + +var x2ManyCommands = { + // (0, _, {values}) + CREATE: 0, + create: function (values) { + return [x2ManyCommands.CREATE, false, values]; + }, + // (1, id, {values}) + UPDATE: 1, + update: function (id, values) { + 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({ + /** + * @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.localData = Object.create(null); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a default record to a list object. This method actually make a new + * record with the _makeDefaultRecord method, then add it to the list object. + * + * @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 {Deferred<string>} resolves to the id of the new created record + */ + addDefaultRecord: function (listID, options) { + var list = this.localData[listID]; + var context = this._getContext(list); + + var position = (options && options.position) || 'top'; + var params = { + context: context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + parentID: list.id, + viewType: list.viewType, + }; + return this._makeDefaultRecord(list.model, params).then(function (id) { + list.count++; + list._changes = list._changes || list.data.slice(0); + if (position === 'top') { + list._changes.unshift(id); + } else { + list._changes.push(id); + } + return id; + }); + }, + /** + * Delete a list of records, then, if a parentID is given, reload the + * parent. + * + * @todo we should remove the deleted records from the localData + * @todo is it really necessary to reload the data? it seems artificial, and + * the caller should be able to do that himself + * @todo why can't we infer modelName? + * + * @param {string[]} recordIds list of local resources ids. They should all + * be of type 'record', and of the same model + * @param {string} modelName + * @param {string} parentID + * @returns {Deferred} + */ + deleteRecords: function (recordIds, modelName, parentID) { + var self = this; + var records = _.map(recordIds, function (id) { return self.localData[id]; }); + return this._rpc({ + model: modelName, + method: 'unlink', + args: [_.pluck(records, 'res_id')], + context: session.user_context, // todo: combine with view context + }) + .then(function () { + _.each(records, function (record) { + record.res_ids.splice(record.offset, 1); + record.res_id = record.res_ids[record.offset]; + record.count--; + }); + if (parentID) { + return self.reload(parentID); + } + }); + }, + /** + * 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]; + this._visitChildren(element, function (elem) { + elem._changes = options.rollback && elem._savePoint || null; + }); + }, + /** + * Duplicate a record (by calling the 'copy' route) + * + * @param {string} recordID id for a local resource + * @returns {Deferred -> string} resolves to the id of duplicate record + */ + duplicateRecord: function (recordID) { + var self = this; + var record = this.localData[recordID]; + return this._rpc({ + model: record.model, + method: 'copy', + args: [record.data.id], + context: this._getContext(record), + }) + .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, + }); + }); + }, + /** + * 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 + * @param {boolean} [options.noUnsetNumeric=false] if true, will set numeric + * values to 0 if not set + * @returns {Object} + */ + get: function (id, options) { + var self = this; + options = options || {}; + + if (!(id in this.localData)) { + return null; + } + + var record = this.localData[id]; + + if (options.env) { + var env = { + ids: record.res_ids ? record.res_ids.slice(0) : [], + }; + if (record.type === 'record') { + env.currentId = this.isNew(record.id) ? undefined : record.res_id; + } + return env; + } + + // do not copy fields: this has a really big performance cost, for views + // with many records and lots of fields (for ex, kanban view contacts) + var fields = record.fields; + delete record.fields; + var element = $.extend(true, {}, record); + record.fields = fields; + element.fields = fields; + + var field, relDataPoint; + if (element.type === 'record') { + // apply changes + _.extend(element.data, element._changes); + + for (var fieldName in element.data) { + field = element.fields[fieldName]; + if (element.data[fieldName] === null) { + element.data[fieldName] = false; + } + if (!field) { + continue; + } + if (options.noUnsetNumeric) { + if (field.type === 'float' || + field.type === 'integer' || + field.type === 'monetary') { + element.data[fieldName] = element.data[fieldName] || 0; + } + } + + // get relational datapoint + if (field.type === 'many2one') { + if (options.raw) { + relDataPoint = this.localData[element.data[fieldName]]; + element.data[fieldName] = relDataPoint ? relDataPoint.res_id : false; + } else { + element.data[fieldName] = this.get(element.data[fieldName]) || false; + } + } + if (field.type === 'one2many' || field.type === 'many2many') { + if (options.raw) { + relDataPoint = this.localData[element.data[fieldName]]; + var ids = _.map(relDataPoint.data, function (id) { + return self.localData[id].res_id; + }); + element.data[fieldName] = ids; + } else { + element.data[fieldName] = this.get(element.data[fieldName]) || []; + } + } + } + + // this is not strictly necessary, but it hides some implementation + // details, and can easily be removed if needed. + delete element.orderedBy; + delete element.aggregateValues; + delete element.groupedBy; + if (this.isNew(element.id)) { + delete element.res_id; + } + } + if (element.type === 'list') { + // apply changes if any + if (element._changes) { + element.data = element._changes; + element.count = element._changes.length; + element.res_ids = _.map(element._changes, function (elemID) { + return self.localData[elemID].res_id; + }); + } + // get relational datapoint + element.data = _.map(element.data, function (elemID) { + return self.get(elemID); + }); + } + + delete element._cache; + delete element._changes; + delete element._forceM2MLink; + delete element.static; + delete element.parentID; + delete element.relation_field; + delete element.rawContext; + + return element; + }, + /** + * return true if a record is dirty. A record is considered dirty if it has + * some unsaved changes. A list is considered dirty if its _changes key is + * set to an array of its new datapoints (possibly empty) + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + isDirty: function (id) { + var isDirty = false; + var record = this.localData[id]; + this._visitChildren(record, function (r) { + if (r.type === "record" ? !_.isEmpty(r._changes) : r._changes) { + isDirty = true; + } + }); + return isDirty; + }, + /** + * Check if a record is new, meaning if it is in the process of being + * created and no actual record exists in db. + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + isNew: function (id) { + return typeof this.localData[id].res_id !== 'number'; + }, + /** + * 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 + * + * @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 {Deferred -> string} resolves to a local id, or handle + */ + load: function (params) { + 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) { + return this._makeDefaultRecord(params.modelName, params); + } + var dataPoint = this._makeDataPoint(params); + return this._load(dataPoint).then(function () { + return dataPoint.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 {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'); + }); + 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; + 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, + }, + }); + record.data[field.name] = dataPoint.id; + if (display_name === undefined) { + defs.push(self._fetchNameGet(dataPoint)); + } + } + } 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, + 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, + type: 'record', + }); + dataPoint.data.push(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 $.when.apply($, 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 + * @returns {string[]} list of changed fields + */ + notifyChanges: function (record_id, changes) { + return this.mutex.exec(this._applyChange.bind(this, record_id, changes)); + }, + /** + * Reload all data for a given resource + * + * @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 {Deferred -> string} resolves to the id of the resource + */ + reload: function (id, options) { + options = options || {}; + var element = this.localData[id]; + + if (element.type === 'record') { + if ('currentId' in options && !options.currentId) { + var params = { + context: element.context, + fieldsInfo: element.fieldsInfo, + fields: element.fields, + viewType: element.viewType, + }; + return this._makeDefaultRecord(element.model, params); + } + if (!options.keepChanges) { + this.discardChanges(id); + } + } + + if (options.context !== undefined) { + element.context = options.context; + } + 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.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; + }); + }, + /** + * 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 an item' 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]; + parent._changes = _.without(parent._changes, elementID); + }, + /** + * 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} record_id 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 + * @returns {Deferred} + */ + save: function (record_id, options) { + var self = this; + return this.mutex.exec(function () { + options = options || {}; + var record = self.localData[record_id]; + if (options.savePoint) { + if (record._changes) { + record._savePoint = _.extend(record._savePoint || {}, record._changes); + } + return $.when(); + } + var shouldReload = 'reload' in options ? options.reload : true; + var method = self.isNew(record_id) ? 'create' : 'write'; + var changes = self._generateChanges(record); + + if (method === 'create') { + var fieldNames = record.getFieldNames(); + _.each(fieldNames, function (name) { + changes[name] = changes[name] || record.data[name]; + if (changes[name] === null) { + delete changes[name]; + } + }); + } + // make sure we don't write an undefined id + delete changes.id; + + // remove readonly fields from the list of changes + if (method === 'write') { + for (var fieldName in changes) { + if (record.fields[fieldName].readonly) { + delete changes[fieldName]; + } + } + } + + // in the case of a write, only perform the RPC if there are changes to save + if (method === 'create' || Object.keys(changes).length) { + var args = method === 'write' ? [[record.data.id], changes] : [changes]; + return self._rpc({ + model: record.model, + method: method, + args: args, + context: session.user_context // todo: combine with view context + }).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++; + } + if (shouldReload) { + // erase changes as they have been applied + record._changes = {}; + return self._fetchRecord(record); + } else { + _.extend(record.data, record._changes); + record._changes = {}; + return false; + } + }); + } else { + return $.when(record_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} recordID a valid element ID + * @param {Object} viewInfo + * @param {Object} viewInfo.fields + * @param {Object} viewInfo.fieldsInfo + */ + addFieldsInfo: function (recordID, viewInfo) { + var record = this.localData[recordID]; + record.fields = _.defaults(record.fields, viewInfo.fields); + record.fieldsInfo = _.defaults(record.fieldsInfo, viewInfo.fieldsInfo); + }, + /** + * For list resources, this changes the orderedBy key, then performs the + * sort directly, in javascript. This is used for sorting static datasets, + * such as a one2many in a form view. For dynamic datasets, such as a list + * view, this method will be called, but then the sort will be ignored since + * we will reload data. + * + * @todo don't sort in js when we reload data anyway + * + * @param {string} list_id id for the list resource + * @param {string} fieldName valid field name + * @returns {this} so we can chain call this method + */ + setSort: function (list_id, fieldName) { + var list = this.localData[list_id]; + if (list.type === 'record') { + return; + } + list.offset = 0; + if (list.orderedBy.length === 0) { + list.orderedBy.push({name: fieldName, asc: true}); + } else if (list.orderedBy[0].name === fieldName){ + 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); + } + this._sortList(list); + return this; + }, + /** + * 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 {Deferred<string>} resolves to the parent id + */ + toggleActive: function (recordIDs, value, 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: 'write', + args: [resIDs, { active: value }], + }) + .then(this.reload.bind(this, parentID)); + + }, + /** + * Toggle (open/close) a group in a grouped list, then fetches relevant + * data + * + * @param {string} groupId + * @returns {Deferred -> string} resolves to the group id + */ + toggleGroup: function (groupId) { + var group = this.localData[groupId]; + if (group.isOpen) { + group.isOpen = false; + group.data = []; + group.offset = 0; + return $.when(groupId); + } + if (!group.isOpen) { + group.isOpen = true; + var def; + if (group.count > 0) { + def = this._load(group); + } + return $.when(def).then(function () { + return groupId; + }); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * 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 + * @returns {Deferred} + */ + _applyChange: function (recordID, changes) { + var self = this; + var record = this.localData[recordID]; + var onChangeFields = []; // the fields that have changed and that have an on_change + var field; + var defs = []; + record._changes = record._changes || {}; + + // apply changes to local data + for (var fieldName in changes) { + field = record.fields[fieldName]; + if (field.type === 'one2many' || field.type === 'many2many') { + defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName])); + } else if (field.type === 'many2one') { + defs.push(this._applyMany2OneChange(record, fieldName, changes[fieldName])); + } else { + record._changes[fieldName] = changes[fieldName]; + } + if (field.onChange) { + onChangeFields.push(fieldName); + } + } + + return $.when.apply($, defs).then(function () { + var onchangeDef; + if (onChangeFields.length) { + onchangeDef = self._applyOnChange(record, onChangeFields).then(function (result) { + delete record._warning; + return _.keys(changes).concat(Object.keys(result && result.value || {})); + }); + } else { + onchangeDef = $.Deferred().resolve(_.keys(changes)); + } + return onchangeDef.then(function (fieldNames) { + _.each(fieldNames, function (name) { + if (record._changes && record._changes[name] === record.data[name]) { + delete record._changes[name]; + } + }); + 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 a many2one onchange. 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. + * + * @param {Object} record + * @param {string} fieldName + * @param {Object} [data] + * @returns {Deferred} + */ + _applyMany2OneChange: function (record, fieldName, data) { + var self = this; + if (!data) { + record._changes[fieldName] = false; + return $.when(); + } + var rel_data = _.pick(data, 'id', 'display_name'); + var def; + if (rel_data.display_name === undefined) { + var field = record.fields[fieldName]; + // TODO: refactor this to use _fetchNameGet + def = this._rpc({ + model: field.relation, + method: 'name_get', + args: [data.id], + context: record.context, + }) + .then(function (result) { + rel_data.display_name = result[0][1]; + }); + } + return $.when(def).then(function () { + var rec = self._makeDataPoint({ + context: record.context, + data: rel_data, + fields: {}, + fieldsInfo: {}, + modelName: record.fields[fieldName].relation, + }); + record._changes[fieldName] = rec.id; + }); + }, + /** + * This method is quite important: it is supposed to perform the /onchange + * rpc and apply the result. + * + * @param {Object} record + * @param {string[]} fields changed fields + * @returns {Deferred} The returned deferred can fail, in which case the + * fail value will be the warning message received from the server + */ + _applyOnChange: function (record, fields) { + var self = this; + var onchange_spec = this._buildOnchangeSpecs(record); + var idList = record.data.id ? [record.data.id] : []; + var options = {}; + if (fields.length === 1) { + fields = fields[0]; + // if only one field changed, add its context to the RPC context + options.fieldName = fields; + } + var context = this._getContext(record, options); + var currentData = this._generateOnChangeData(record); + + return self._rpc({ + model: record.model, + method: 'onchange', + args: [idList, currentData, fields, onchange_spec, context], + }) + .then(function (result) { + 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) { + self.trigger_up('warning', { + message: result.warning.message, + title: result.warning.title, + type: 'dialog', + }); + record._warning = true; + } + var defs = []; + _.each(result.value, function (val, name) { + var field = record.fields[name]; + if (!field) { return; } // ignore changes of unknown fields + + var rec; + if (field.type === 'many2one' ) { + // 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. + // FIXME: shouldn't we erase the value in that case? + 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}; + rec = self._makeDataPoint({ + context: context, + data: data, + modelName: field.relation, + }); + record._changes[name] = rec.id; + } + } else if (field.type === 'one2many' || field.type === 'many2many') { + record._changes = record._changes || {}; + var listId = record._changes[name] || record.data[name]; + var list = self.localData[listId]; + _.each(val, function (command) { + if (command[0] === 0 || command[0] === 1) { + // CREATE or UPDATE + var params = { + context: context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + modelName: list.model, + parentID: list.id, + viewType: list.viewType, + }; + if (command[0] === 1) { + params.res_id = command[1]; + } + rec = self._makeDataPoint(params); + var data = {}; + _.each(Object.keys(command[2]), function (name) { + var field = rec.fields[name]; + if (!field) { + // ignore if this field is unknown as we don't + // know if it requires a special handling + return; + } + data[name] = command[2][name]; + if (field.type === 'many2one') { + var r = self._makeDataPoint({ + modelName: field.relation, + data: { + id: data[name][0], + display_name: data[name][1], + }, + }); + data[name] = r.id; + } + }); + rec._changes = data; + list._changes.push(rec.id); + } + if (command[0] === 5) { + // DELETE ALL + list._changes = []; + } + }); + } else if (field.type === 'date') { + // process data: convert into a moment instance + record._changes[name] = fieldUtils.parse.date(val); + } else if (field.type === 'datetime') { + // process datetime: convert into a moment instance + record._changes[name] = fieldUtils.parse.datetime(val); + } else { + record._changes[name] = val; + } + }); + return $.when.apply($, defs).then(function () { + return result; + }); + }); + }, + /** + * 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'} + * @returns {Deferred} + */ + _applyX2ManyChange: function (record, fieldName, command) { + var self = this; + var list = this.localData[record._changes[fieldName] || record.data[fieldName]]; + var field = record.fields[fieldName]; + var fieldInfo = record.fieldsInfo[record.viewType][fieldName]; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var rec; + var defs = []; + list._changes = list._changes || list.data.slice(0); + + 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._changes.push(newRecord.id); + 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]; + 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, + viewType: view ? view.type : fieldInfo.viewType, + }); + list_records[d.id] = rec; + list._changes.push(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(); + var def = this._rpc({ + model: list.model, + method: 'read', + args: [_.pluck(data, 'id'), fieldNames] + }) + .then(function (records) { + _.each(records, function (record) { + list_records[record.id].data = record; + self._parseServerData(fieldNames, list.fields, record); + }); + }); + defs.push(def); + break; + case 'CREATE': + if (command.data) { + defs.push(this.addDefaultRecord(list.id).then(function (id) { + return this.notifyChanges(id, command.data); + })); + } else { + defs.push(this.addDefaultRecord(list.id, {position: command.position})); + } + break; + case 'UPDATE': + defs.push(this._applyChange(command.id, command.data)); + break; + case 'REMOVE': + list._changes = _.without(list._changes, command.id); + break; + 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 + var currentIds = _.map(list._changes, function (localId) { + return self.localData[localId].res_id; + }); + var newIds = _.difference(command.ids, currentIds); + var removedIds = _.difference(currentIds, command.ids); + var addDef, removedDef; + if (newIds.length) { + var values = _.map(newIds, function (id) { + return {id: id}; + }); + + addDef = this._applyX2ManyChange(record, fieldName, { + operation: 'ADD_M2M', + ids: values + }); + } + if (removedIds.length) { + removedDef = this._applyX2ManyChange(record, fieldName, { + operation: 'REMOVE', + ids: removedIds + }); + } + return $.when(addDef, removedDef); + } + + return $.when.apply($, defs); + }, + /** + * 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"} + * + * @see _applyOnChange + * + * @param {Object} record resource object of type 'record' + * @returns {Object} an onchange spec + */ + _buildOnchangeSpecs: function (record) { + // TODO: replace this function by some generic tree function in utils + var specs = {}; + _.each(record.getFieldNames(), function (name) { + var field = record.fields[name]; + var fieldInfo = record.fieldsInfo[record.viewType][name]; + specs[name] = (field.onChange) || ""; + _.each(fieldInfo.views, function (view) { + _.each(view.fieldsInfo[view.type], function (field, subname) { + specs[name + '.' + subname] = (view.fields[subname].onChange) || ""; + }); + }); + }); + return specs; + }, + /** + * Fetch all name_gets for the many2ones in a group + * + * @param {Object} group a valid resource object + * @returns {Deferred} + */ + _fetchMany2OneGroup: function (group) { + var ids = _.uniq(_.pluck(group, 'res_id')); + + return this._rpc({ + model: group[0].model, + method: 'name_get', + args: [ids], + context: group[0].context + }) + .then(function (name_gets) { + _.each(group, function (record) { + var nameGet = _.find(name_gets, function (n) { return n[0] === record.res_id;}); + record.data.display_name = nameGet[1]; + }); + }); + }, + /** + * Fetch name_get for a record datapoint. + * + * @param {Object} dataPoint + * @returns {Deferred} + */ + _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]; + }); + }, + _fetchNameGets: function (list, fieldName) { + var self = this; + var model; + var records = []; + var ids = _.map(list._changes || list.data, function (localId) { + var record = self.localData[localId]; + var data = record._changes || record.data; + var many2oneId = data[fieldName]; + var many2oneRecord = self.localData[many2oneId]; + records.push(many2oneRecord); + model = many2oneRecord.model; + return many2oneRecord.res_id; + }); + return this._rpc({ + model: model, + method: 'name_get', + args: [ids], + context: list.context, + }) + .then(function (name_gets) { + for (var i = 0; i < name_gets.length; i++) { + records[i].data.display_name = name_gets[i][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} [optinos.viewType] the type of view for which the record + * is fetched (usefull to load the adequate fields), by defaults, uses + * record.viewType + * @returns {Deferred -> Object} resolves to the record + */ + _fetchRecord: function (record, options) { + var self = this; + var fieldNames = options && options.fieldNames || + _.uniq(record.getFieldNames().concat(['display_name'])); + return this._rpc({ + model: record.model, + method: 'read', + args: [[record.res_id], fieldNames], + // context: {bin_size: true} // FIXME: when editing a subrecord in the partner form view, it tries to write the bin_size on the image field + }) + .then(function (result) { + result = result[0]; + record.data = _.extend({}, record.data, result); + }) + .then(function () { + self._parseServerData(fieldNames, record.fields, record.data); + }) + .then(function () { + return self._fetchX2Manys(record, options).then(function () { + return self._postprocess(record); + }); + }); + }, + /** + * This method is incorrectly named. It should be named something like + * _fetchMany2OneData. + * + * For a given record, this method fetches all many2ones information, + * batching the requests if possible (for example, if 3 many2ones are in + * relation on the same model, then we can probably fetch them in one rpc) + * + * This method is currently only called by _makeDefaultRecord, it should be + * called by the onchange methods at some point. + * + * @todo fix bug: returns a list of deferred, not a deferred + * + * @param {Object} record a valid resource object + * @returns {Deferred} + */ + _fetchRelationalData: function (record) { + var self = this; + var toBeFetched = []; + + // find all many2one related records to be fetched + _.each(record.getFieldNames(), function (name) { + var field = record.fields[name]; + if (field.type === 'many2one') { + var localId = (record._changes && record._changes[name]) || record.data[name]; + var relatedRecord = self.localData[localId]; + if (!relatedRecord) { + return; + } + toBeFetched.push(relatedRecord); + } + }); + + // group them by model and context. Using the context as key is + // necessary to make sure the correct context is used for the rpc; + var groups = _.groupBy(toBeFetched, function (rec) { + return [rec.model, JSON.stringify(rec.context)].join(); + }); + + return $.when.apply($, _.map(groups, this._fetchMany2OneGroup.bind(this))); + }, + /** + * 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 + * @returns {Deferred<Array>} + * The deferred is resolved with an array containing the names of + * the field whose special data has been changed. + */ + _fetchSpecialData: function (record) { + var self = this; + var fieldNames = []; + return $.when.apply($, _.map(record.getFieldNames(), function (name) { + var fieldInfo = record.fieldsInfo[record.viewType][name] || {}; + var Widget = fieldInfo.Widget; + if (Widget && Widget.prototype.specialData) { + return self[Widget.prototype.specialData](record, name).then(function (data) { + if (data === undefined) { + return; + } + record.specialData[name] = data; + fieldNames.push(name); + }); + } + })).then(function () { + return fieldNames; + }); + }, + /** + * 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 {string[]} [fieldsToRead] - the m2os fields to read (id and + * display_name are automatic). + * @returns {Deferred<any>} + * The deferred 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 deferred is resolved with + * the undefined value. + */ + _fetchSpecialMany2ones: function (record, fieldName, fieldsToRead) { + var field = record.fields[fieldName]; + if (field.type !== "many2one") { + return $.when(); + } + + var context = record.getContext({fieldName: fieldName}); + var domain = record.getDomain({fieldName: fieldName}); + if (domain.length) { + var localID = 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 $.when(); + } + + 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 {Deferred<any>} + * The deferred 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 deferred is resolved with + * the undefined value. + */ + _fetchSpecialRelation: function (record, fieldName) { + var field = record.fields[fieldName]; + if (!_.contains(["many2one", "many2many", "one2many"], field.type)) { + return $.when(); + } + + 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 $.when(); + } + + return this._rpc({ + model: field.relation, + method: 'name_search', + args: ["", domain], + context: context + }); + }, + /** + * 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 + * @returns {Deferred<any>} + * The deferred 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 deferred is resolved with + * the undefined value. + */ + _fetchSpecialStatus: function (record, fieldName) { + var foldField = record.fieldsInfo[record.viewType][fieldName].options.fold_field; + var fieldsToRead = foldField ? [foldField] : []; + return this._fetchSpecialMany2ones(record, fieldName, 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 + * @returns {Deferred<any>} + * The deferred 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 deferred is resolved with + * the undefined value. + */ + _fetchSpecialDomain: function (record, fieldName) { + var context = record.getContext({fieldName: fieldName}); + + var domainModel = record.fieldsInfo[record.viewType][fieldName].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 $.when(); + } + + var def = $.Deferred(); + + this._rpc({ + model: domainModel, + method: 'search_count', + args: [Domain.prototype.stringToArray(domainValue)], + context: context + }) + .then(_.identity, function (error, e) { + e.preventDefault(); // prevent traceback (the search_count might be intended to break) + return false; + }) + .always(function (nbRecords) { + def.resolve({ + model: domainModel, + nbRecords: nbRecords, + }); + }); + + return def; + }, + /** + * Fetch all data in a ungrouped list + * + * @param {Object} list a valid resource object + * @returns {Deferred -> Object} resolves to the fecthed list + */ + _fetchUngroupedList: function (list) { + var self = this; + var def; + if (list.static) { + def = this._readUngroupedList(list); + } else { + def = this._searchReadUngroupedList(list); + } + return def.then(function () { + return self._fetchX2ManysBatched(list); + }).then(function () { + return list; + }); + }, + /** + * 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 {Deferred} + */ + _fetchX2Manys: function (record, options) { + var self = this; + var defs = []; + var fieldNames = options && options.fieldNames || record.getFieldNames(); + var viewType = options && 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, + fieldsInfo: fieldsInfo, + fields: view ? view.fields : fieldInfo.relatedFields, + limit: fieldInfo.limit, + modelName: field.relation, + res_ids: ids, + static: true, + type: 'list', + 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 self._fetchX2ManysBatched(list); + }); + defs.push(def); + } + } + }); + return $.when.apply($, defs); + }, + /** + * batch requests for 1 x2m in list + * + * @see _fetchX2ManysBatched + * @param {Object} list + * @param {string} fieldName + * @returns {Deferred} + */ + _fetchX2ManyBatched: 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]; + + // step 1: collect ids + var ids = []; + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + ids = _.unique(ids.concat(record.data[fieldName] || [])); + var m2mList = self._makeDataPoint({ + fieldsInfo: fieldInfo.fieldsInfo || view && view.fieldsInfo, + fields: fieldInfo.relatedFields || view && view.fields, + modelName: field.relation, + res_ids: record.data[fieldName], + static: true, + type: 'list', + }); + record.data[fieldName] = m2mList.id; + }); + + if (!ids.length || fieldInfo.__no_fetch) { + return $.when(); + } + + // step 2: fetch data from server + return this._rpc({ + model: field.relation, + method: 'read', + args: [ids, _.keys(fieldInfo.relatedFields)], + context: {}, // FIXME + }) + .then(function (results) { + // step 3: assign values to correct datapoints + var dataPoints = _.map(results, function (result) { + return self._makeDataPoint({ + modelName: field.relation, + data: result, + fieldsInfo: fieldInfo.fieldsInfo || view && view.fieldsInfo, + fields: fieldInfo.relatedFields || view && view.fields, + }); + }); + + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + var m2mList = self.localData[record.data[fieldName]]; + + m2mList.data = []; + _.each(m2mList.res_ids, function (res_id) { + var dataPoint = _.find(dataPoints, function (d) { + return d.res_id === res_id; + }); + m2mList.data.push(dataPoint.id); + m2mList.count++; + }); + }); + }); + }, + /** + * batch request for x2ms for datapoint of type list + * + * @param {Object} list + * @returns {Deferred} + */ + _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 $.when.apply($, 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 + * @returns {Object} a map from changed fields to their new value + */ + _generateChanges: function (record) { + var changes = _.extend({}, record._changes); + var commands = this._generateX2ManyCommands(record, true); + for (var fieldName in record.fields) { + var type = record.fields[fieldName].type; + if (type === 'one2many' || type === 'many2many') { + if (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) { + var value = changes[fieldName]; + changes[fieldName] = value ? this.localData[value].res_id : 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 + * @returns {Object} the data + */ + _generateOnChangeData: function (record) { + var commands = this._generateX2ManyCommands(record, false); + var data = _.extend(this.get(record.id, {raw: true}).data, commands); + + // 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 {boolean} [changesOnly=false] if true, only generates commands for + * fields that have changed + * @returns {Object} a map from some field names to commands + */ + _generateX2ManyCommands: function (record, changesOnly) { + var self = this; + var commands = {}; + var data = _.extend({}, record.data, record._changes); + var type; + for (var fieldName in record.fields) { + type = record.fields[fieldName].type; + + if (type === 'many2many' || type === 'one2many') { + commands[fieldName] = []; + if (!data[fieldName]) { + // skip if this field is empty + continue; + } + var list = this.localData[data[fieldName]]; + if (changesOnly && !list._changes) { + // if only changes are requested, skip if there is no change + continue; + } + var relData = _.map(list._changes || list.data, function (localId) { + return self.localData[localId]; + }); + var relIds = _.pluck(relData, 'res_id'); + if (type === 'many2many' || list._forceM2MLink) { + // 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 + // an complete override of the actual value of the m2m) + commands[fieldName].push(x2ManyCommands.replace_with(relIds)); + } else if (type === 'one2many') { + var removedIds = _.difference(list.res_ids, relIds); + var addedIds = _.difference(relIds, list.res_ids); + var keptIds = _.intersection(list.res_ids, relIds); + + // the didChange variable keeps track of the fact that at + // least one id was updated + var didChange = false; + var command, relRecord; + for (var i = 0; i < relIds.length; i++) { + if (_.contains(keptIds, relIds[i])) { + // this is an id that already existed + relRecord = _.findWhere(relData, {res_id: relIds[i]}); + if (!_.isEmpty(relRecord._changes)) { + var changes = this._generateChanges(relRecord); + command = x2ManyCommands.update(relRecord.res_id, changes); + didChange = true; + } else { + command = x2ManyCommands.link_to(relIds[i]); + } + commands[fieldName].push(command); + } else if (_.contains(addedIds, relIds[i])) { + // this is a new id + relRecord = _.findWhere(relData, {res_id: relIds[i]}); + relRecord = this.get(relRecord.id, {raw: true}); + commands[fieldName].push(x2ManyCommands.create(_.omit(relRecord.data, 'id'))); + } + } + if (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++) { + 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] + * the name of the field whose context needs to be added to the + * result (and evaluated) + * @returns {Object} the evaluated context + */ + _getContext: function (element, options) { + var context = new Context(session.user_context, element.context); + context.set_eval_context(this._getEvalContext(element)); + + if (options && options.additionalContext) { + context.add(options.additionalContext); + } + if (options && 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 (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(); + }, + /** + * 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) { + 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) + ); + } + var fieldParams = element.fields[options.fieldName]; + if (fieldParams.domain) { + return Domain.prototype.stringToArray( + fieldParams.domain, + this._getEvalContext(element) + ); + } + return []; + } + + return Domain.prototype.stringToArray( + element.domain, + this._getEvalContext(element) + ); + }, + /** + * 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 + * @returns {Object} + */ + _getEvalContext: function (element) { + var evalContext = this.get(element.id, {raw: true, noUnsetNumeric: true}).data; + evalContext.active_model = element.model; + if (evalContext.id) { + evalContext.active_id = evalContext.id; + evalContext.active_ids = [evalContext.id]; + } + if (element.parentID) { + var parent = this.get(element.parentID, {raw: true}); + if (parent.type === 'list' && this.localData[element.parentID].parentID) { + parent = this.get(this.localData[element.parentID].parentID, {raw: true}); + } + _.extend(evalContext, {parent: parent.data}); + } + return _.extend({}, session.user_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 + * @returns {string[]} the list of field names + */ + _getFieldNames: function (element) { + return Object.keys(element.fieldsInfo[element.viewType] || {}); + }, + /** + * 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 + * @returns {Deferred} + */ + _load: function (dataPoint, options) { + if (dataPoint.type === 'record') { + return this._fetchRecord(dataPoint, options); + } + if (dataPoint.type === 'list' && dataPoint.groupedBy.length) { + return this._readGroup(dataPoint); + } + if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) { + return this._fetchUngroupedList(dataPoint); + } + }, + /** + * Turns a bag of properties into a valid local resource. Also, register + * the resource in the localData object. + * + * @param {Object} params + * @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field + * @param {Array} [params.fieldNames] the name of fields to load, the list + * of all fields by default + * @param {Object} params.fields contains the description of each field + * @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 || []; + if (type === 'record') { + res_id = params.res_id || (params.data && params.data.id) || _.uniqueId('virtual_'); + } 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, + aggregateValues: params.aggregateValues || {}, + context: params.context || {}, + count: params.count || res_ids.length, + data: params.data || (type === 'record' ? {} : []), + domain: params.domain || [], + fields: fields, + fieldsInfo: params.fieldsInfo, + groupedBy: params.groupedBy || [], + id: _.uniqueId(params.modelName + '_'), + isOpen: params.isOpen, + limit: type === 'record' ? 1 : params.limit, + model: params.modelName, + offset: params.offset || (type === 'record' ? _.indexOf(res_ids, res_id) : 0), + openGroupByDefault: params.openGroupByDefault, + orderedBy: params.orderedBy || [], + parentID: params.parentID, + rawContext: params.rawContext, + 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, + }; + + dataPoint.getContext = this._getContext.bind(this, dataPoint); + dataPoint.getEvalContext = this._getEvalContext.bind(this, dataPoint); + dataPoint.getDomain = this._getDomain.bind(this, dataPoint); + dataPoint.getFieldNames = this._getFieldNames.bind(this, dataPoint); + + 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 {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 {Deferred -> string} resolves to the id for the created resource + */ + _makeDefaultRecord: function (modelName, params) { + var self = this; + var fieldNames = Object.keys(params.fieldsInfo[params.viewType]); + var fields_key = _.without(fieldNames, '__last_update'); + + return this._rpc({ + model: modelName, + method: 'default_get', + args: [fields_key], + context: params.context, + }) + .then(function (result) { + // fill default values for missing fields + for (var i = 0; i < fieldNames.length; i++) { + var fieldName = fieldNames[i]; + if (!(fieldName in result)) { + var field = params.fields[fieldName]; + if (field.type === 'one2many' || field.type === 'many2many') { + result[fieldName] = []; + } else { + result[fieldName] = null; + } + } + } + + var data = {}; + var record = self._makeDataPoint({ + modelName: modelName, + data: data, + fields: params.fields, + fieldsInfo: params.fieldsInfo, + context: params.context, + parentID: params.parentID, + res_ids: params.res_ids, + viewType: params.viewType, + }); + + var defs = []; // FIXME: remove defs? + _.each(fieldNames, function (name) { + var field = params.fields[name]; + data[name] = null; + record._changes = record._changes || {}; + if (field.type === 'many2one' && result[name]) { + var rec = self._makeDataPoint({ + context: record.context, + data: {id: result[name]}, + modelName: field.relation, + }); + record._changes[name] = rec.id; + } else if (field.type === 'one2many' || field.type === 'many2many') { + var fieldInfo = record.fieldsInfo[record.viewType][name]; + 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 x2manyList = self._makeDataPoint({ + context: record.context, + 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[name] = x2manyList.id; + var many2ones = {}; + var r; + _.each(result[name], function (value) { + if (_.isArray(value)) { + // value is a command + if (value[0] === 0) { + // CREATE + r = self._makeDataPoint({ + modelName: x2manyList.model, + context: x2manyList.context, + fieldsInfo: fieldsInfo, + fields: fields, + viewType: viewType, + }); + x2manyList._changes = x2manyList._changes || []; + x2manyList._changes.push(r.id); + r._changes = value[2]; + + // this is necessary so the fields are initialized + for (var fieldName in value[2]) { + r.data[fieldName] = null; + } + + for (var name in r._changes) { + var isFieldInView = name in r.fields; + if (isFieldInView && r.fields[name].type === 'many2one') { + var rec = self._makeDataPoint({ + context: r.context, + modelName: r.fields[name].relation, + data: {id: r._changes[name]} + }); + r._changes[name] = rec.id; + many2ones[name] = true; + } + } + } + if (value[0] === 6) { + // REPLACE_WITH + x2manyList._changes = []; + } + } else { + // value is an id + r = self._makeDataPoint({ + modelName: x2manyList.model, + context: x2manyList.context, + fieldsInfo: fieldsInfo, + fields: fields, + res_id: value, + viewType: viewType, + }); + if (!x2manyList._changes) { + x2manyList._changes = []; + } + x2manyList._changes.push(r.id); + } + }); + + // fetch many2ones display_name + _.each(_.keys(many2ones), function (name) { + defs.push(self._fetchNameGets(x2manyList, name)); + }); + } else if (field.type === 'date') { + // process date: convert into a moment instance + record._changes[name] = fieldUtils.parse.date(result[name]); + } else if (field.type === 'datetime') { + // process datetime: convert into a moment instance + record._changes[name] = fieldUtils.parse.datetime(result[name]); + } else { + record._changes[name] = result[name]; + } + }); + return $.when.apply($, defs) + .then(function () { + var shouldApplyOnchange = false; + var field; + for (var field_name in record.data) { + field = record.fields[field_name]; + if (field.onChange) { + shouldApplyOnchange = true; + } + } + if (shouldApplyOnchange) { + return self._applyOnChange(record, fields_key).then(function () { + if (record._warning) { + return $.Deferred().reject(); + } + }); + } else { + return $.when(); + } + }) + .then(function () { + return self._fetchRelationalData(record); + }) + .then(function () { + return self._postprocess(record); + }) + .then(function () { + return record.id; + }); + }); + }, + /** + * parse the server values to javascript framwork + * + * @param {[string]} fieldNames + * @param {Object} fields + * @param {Object} record + */ + _parseServerData: function (fieldNames, fields, record) { + var self = this; + _.each(fieldNames, function (fieldName) { + var field = fields[fieldName]; + var val = record[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], + }, + }); + record[fieldName] = r.id; + } else { + // no value for the many2one + record[fieldName] = false; + } + } else if (field.type === 'date') { + // process data: convert into a moment instance + record[fieldName] = fieldUtils.parse.date(val); + } else if (field.type === 'datetime') { + // process datetime: convert into a moment instance + record[fieldName] = fieldUtils.parse.datetime(val); + } + }); + }, + /** + * 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 {any} record + * @returns {Deferred -> Object} resolves to the finished resource + */ + _postprocess: function (record) { + var self = this; + var defs = []; + _.each(record.getFieldNames(), function (name) { + var field = record.fields[name]; + var fieldInfo = record.fieldsInfo[record.viewType][name] || {}; + var options = fieldInfo.options || {}; + if (options.always_reload) { + if (record.fields[name].type === 'many2one' && record.data[name]) { + var element = self.localData[record.data[name]]; + defs.push(self._rpc({ + model: field.relation, + method: 'name_get', + args: [element.data.id], + context: self._getContext(record, {fieldName: name}), + }) + .then(function (result) { + element.data.display_name = result[0][1]; + })); + } + } + }); + + defs.push(this._fetchSpecialData(record)); + + return $.when.apply($, defs).then(function () { + return record; + }); + }, + /** + * 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 + * @returns {Deferred<Object>} resolves to the fetched group object + */ + _readGroup: function (list) { + var self = this; + var fields = _.uniq(list.getFieldNames().concat(list.groupedBy)); + return this._rpc({ + model: list.model, + method: 'read_group', + fields: fields, + domain: list.domain, + context: list.context, + groupBy: list.groupedBy, + lazy: true, + }) + .then(function (groups) { + var rawGroupBy = list.groupedBy[0].split(':')[0]; + var previousGroups = _.map(list.data, function (groupID) { + return self.localData[groupID]; + }); + list.data = []; + list.count = 0; + var defs = []; + + _.each(groups, function (group) { + var aggregateValues = {}; + _.each(group, function (value, key) { + if (_.contains(fields, key) && key !== list.groupedBy[0]) { + aggregateValues[key] = value; + } + }); + var newGroup = self._makeDataPoint({ + modelName: list.model, + count: group[rawGroupBy + '_count'], + domain: group.__domain, + context: list.context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + value: group[rawGroupBy], + aggregateValues: aggregateValues, + groupedBy: list.groupedBy.slice(1), + orderedBy: list.orderedBy, + limit: list.limit, + openGroupByDefault: list.openGroupByDefault, + type: 'list', + viewType: list.viewType, + }); + list.data.push(newGroup.id); + list.count += newGroup.count; + var oldGroup = _.find(previousGroups, function (g) { + return g.res_id === newGroup.res_id && g.value === newGroup.value; + }); + if (oldGroup) { + // restore the internal state of the group + _.extend(newGroup, _.pick(oldGroup, 'limit', 'isOpen', 'offset')); + // if the group is open and contains subgroups, also + // restore its data to keep internal state of sub-groups + if (newGroup.isOpen && newGroup.groupedBy.length) { + newGroup.data = oldGroup.data; + } + } else if (!newGroup.openGroupByDefault) { + newGroup.isOpen = false; + } else { + newGroup.isOpen = '__fold' in group ? !group.__fold : true; + } + if (newGroup.isOpen && newGroup.count > 0) { + defs.push(self._load(newGroup)); + } + }); + return $.when.apply($, defs).then(function () { + // generate the res_ids of the main list, being the concatenation + // of the fetched res_ids in each group + list.res_ids = _.flatten(_.map(arguments, function (group) { + return group ? group.res_ids : []; + })); + 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 {Deferred -> Object} resolves to the fetched list object + */ + _readUngroupedList: function (list) { + var self = this; + var def; + var ids = []; + var missingIds = []; + var upper_bound = list.limit ? Math.min(list.offset + list.limit, list.count) : list.count; + var fieldNames = list.getFieldNames(); + for (var i = list.offset; i < upper_bound; i++) { + var id = list.res_ids[i]; + ids.push(id); + if (!list._cache[id]) { + missingIds.push(id); + } + } + if (missingIds.length) { + def = this._rpc({ + model: list.model, + method: 'read', + args: [missingIds, fieldNames], + context: {} // FIXME + }); + } else { + def = $.when(); + } + return def.then(function (records) { + list.data = []; + _.each(ids, function (id) { + var dataPoint; + if (id in list._cache) { + dataPoint = self.localData[list._cache[id]]; + } else { + dataPoint = self._makeDataPoint({ + data: _.findWhere(records, {id: id}), + fieldsInfo: list.fieldsInfo, + fields: list.fields, + modelName: list.model, + parentID: list.id, + viewType: list.viewType, + }); + + // add many2one records + self._parseServerData(fieldNames, dataPoint.fields, dataPoint.data); + list._cache[id] = dataPoint.id; + } + list.data.push(dataPoint.id); + }); + self._sortList(list); + return list; + }); + }, + /** + * 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 {Deferred} + */ + _searchReadUngroupedList: function (list) { + var self = this; + var fieldNames = list.getFieldNames(); + return this._rpc({ + route: '/web/dataset/search_read', + model: list.model, + fields: fieldNames, + domain: list.domain || [], + limit: list.limit, + offset: list.offset, + orderBy: list.orderedBy, + }) + .then(function (result) { + list.count = result.length; + var data = _.map(result.records, function (record) { + var dataPoint = self._makeDataPoint({ + data: record, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + modelName: list.model, + parentID: list.id, + viewType: list.viewType, + }); + + // add many2one records + self._parseServerData(fieldNames, dataPoint.fields, dataPoint.data); + return dataPoint.id; + }); + list.data = data; + list.res_ids = _.pluck(result.records, 'id'); + return list; + }); + }, + /** + * 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. Its intended use is for + * static datasets, such as a one2many in a form view. + * + * @param {Object} list + */ + _sortList: function (list) { + var self = this; + if (list.orderedBy.length) { + // sort records according to ordered_by[0] + var order = list.orderedBy[0]; + list.data.sort(function (r1, r2) { + var data1 = self.localData[r1].data; + var data2 = self.localData[r2].data; + if (data1[order.name] < data2[order.name]) { + return order.asc ? -1 : 1; + } + if (data1[order.name] > data2[order.name]) { + return order.asc ? 1 : -1; + } + return 0; + }); + } + }, + /** + * 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. + * + * @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 relationalElement = this.localData[element.data[fieldName]]; + + // relationalElement could be empty in the case of a many2one + if (relationalElement) { + self._visitChildren(relationalElement, fn); + } + } + } + } + if (element.type === 'list') { + _.each(element.data, function (elemId) { + var elem = self.localData[elemId]; + self._visitChildren(elem, fn); + }); + } + }, +}); + +return BasicModel; +}); |
