4275 lines
177 KiB
JavaScript
4275 lines
177 KiB
JavaScript
flectra.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 (flectra) 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');
|
|
var session = require('web.session');
|
|
var utils = require('web.utils');
|
|
|
|
var _t = core._t;
|
|
|
|
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({
|
|
/**
|
|
* @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 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 {Deferred<string>} resolves to the id of the new created record
|
|
*/
|
|
addDefaultRecord: function (listID, options) {
|
|
var self = this;
|
|
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++;
|
|
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;
|
|
});
|
|
},
|
|
/**
|
|
* Add and process default values for a given record. Those values are
|
|
* parsed and stored in the '_changes' key of the record. For relational
|
|
* fields, sub-dataPoints are created, and missing relational data is
|
|
* fetched. Also generate default values for fields with no given value.
|
|
* Typically, this function is called with the result of a 'default_get'
|
|
* RPC, to populate a newly created dataPoint. 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} values dict of default values for the given 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 {Deferred}
|
|
*/
|
|
applyDefaultValues: function (recordID, values, options) {
|
|
options = options || {};
|
|
var record = this.localData[recordID];
|
|
var viewType = options.viewType || record.viewType;
|
|
var fieldNames = options.fieldNames || Object.keys(record.fieldsInfo[viewType]);
|
|
var field;
|
|
var fieldName;
|
|
record._changes = record._changes || {};
|
|
|
|
// ignore values for non requested fields (for instance, fields that are
|
|
// not in the view)
|
|
values = _.pick(values, fieldNames);
|
|
|
|
// fill default values for missing fields
|
|
for (var i = 0; i < fieldNames.length; i++) {
|
|
fieldName = fieldNames[i];
|
|
if (!(fieldName in values) && !(fieldName in record._changes)) {
|
|
field = record.fields[fieldName];
|
|
if (field.type === 'float' ||
|
|
field.type === 'integer' ||
|
|
field.type === 'monetary') {
|
|
values[fieldName] = 0;
|
|
} else if (field.type === 'one2many' || field.type === 'many2many') {
|
|
values[fieldName] = [];
|
|
} else {
|
|
values[fieldName] = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// parse each value and create dataPoints for relational fields
|
|
var defs = [];
|
|
for (fieldName in values) {
|
|
field = record.fields[fieldName];
|
|
record.data[fieldName] = null;
|
|
var dp;
|
|
if (field.type === 'many2one' && values[fieldName]) {
|
|
dp = this._makeDataPoint({
|
|
context: record.context,
|
|
data: {id: values[fieldName]},
|
|
modelName: field.relation,
|
|
parentID: record.id,
|
|
});
|
|
record._changes[fieldName] = dp.id;
|
|
} else if (field.type === 'reference' && values[fieldName]) {
|
|
var ref = values[fieldName].split(',');
|
|
dp = this._makeDataPoint({
|
|
context: record.context,
|
|
data: {id: parseInt(ref[1])},
|
|
modelName: ref[0],
|
|
parentID: record.id,
|
|
});
|
|
defs.push(this._fetchNameGet(dp));
|
|
record._changes[fieldName] = dp.id;
|
|
} else if (field.type === 'one2many' || field.type === 'many2many') {
|
|
defs.push(this._processX2ManyCommands(record, fieldName, values[fieldName], options));
|
|
} else {
|
|
record._changes[fieldName] = this._parseServerValue(field, values[fieldName]);
|
|
}
|
|
}
|
|
|
|
return $.when.apply($, defs);
|
|
},
|
|
/**
|
|
* 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 {Deferred<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;
|
|
});
|
|
},
|
|
/**
|
|
* 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 {Deferred}
|
|
*/
|
|
deleteRecords: function (recordIds, modelName) {
|
|
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) {
|
|
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];
|
|
} else {
|
|
record.res_ids.splice(record.offset, 1);
|
|
record.offset = Math.min(record.offset, record.res_ids.length - 1);
|
|
record.res_id = record.res_ids[record.offset];
|
|
record.count--;
|
|
}
|
|
});
|
|
});
|
|
},
|
|
/**
|
|
* Discard all changes in a local resource. Basically, it removes
|
|
* everything that was stored in a _changes key.
|
|
*
|
|
* @param {string} id local resource id
|
|
* @param {Object} [options]
|
|
* @param {boolean} [options.rollback=false] if true, the changes will
|
|
* be reset to the last _savePoint, otherwise, they are reset to null
|
|
*/
|
|
discardChanges: function (id, options) {
|
|
options = options || {};
|
|
var element = this.localData[id];
|
|
var isNew = this.isNew(id);
|
|
var rollback = 'rollback' in options ? options.rollback : isNew;
|
|
var initialOffset = element.offset;
|
|
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 {Deferred<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,
|
|
});
|
|
});
|
|
},
|
|
/**
|
|
* 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) {
|
|
relDataPoint = this.localData[data[fieldName]];
|
|
relDataPoint = this._applyX2ManyOperations(relDataPoint);
|
|
data[fieldName] = relDataPoint.res_ids;
|
|
} 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,
|
|
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,
|
|
id: element.id,
|
|
isOpen: element.isOpen,
|
|
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;
|
|
},
|
|
/**
|
|
* 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];
|
|
if (record._changes && 'display_name' in record._changes) {
|
|
return record._changes.display_name;
|
|
}
|
|
if ('display_name' in record.data) {
|
|
return record.data.display_name;
|
|
}
|
|
return _t("New");
|
|
},
|
|
/**
|
|
* 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);
|
|
},
|
|
/**
|
|
* 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;
|
|
},
|
|
/**
|
|
* 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
|
|
*
|
|
* @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) {
|
|
params.allowWarning = true;
|
|
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,
|
|
},
|
|
parentID: record.id,
|
|
});
|
|
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,
|
|
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 $.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
|
|
* @param {Object} [options] will be transferred to the applyChange method
|
|
* @see _applyChange
|
|
* @returns {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
|
|
*
|
|
* @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 (!options.currentId && (('currentId' in options) || this.isNew(id))) {
|
|
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, {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.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.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;
|
|
});
|
|
},
|
|
/**
|
|
* 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];
|
|
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 {Deferred<string>} resolves to the local id of the parent
|
|
*/
|
|
resequence: function (modelName, resIDs, parentID, options) {
|
|
options = options || {};
|
|
if ((resIDs.length <= 1)) {
|
|
return $.when(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 () {
|
|
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} 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
|
|
* @param {string} [options.viewType] current viewType. If not set, we will
|
|
* assume main viewType from the record
|
|
* @returns {Deferred}
|
|
* 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
|
|
_.each((record._changes || {}), function (value, fieldName) {
|
|
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 def = $.Deferred();
|
|
var changedFields = Object.keys(changes);
|
|
|
|
if (options.savePoint) {
|
|
return def.resolve(changedFields);
|
|
}
|
|
|
|
def.then(function () {
|
|
record._isDirty = false;
|
|
});
|
|
// 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 = {};
|
|
|
|
self.unfreezeOrder(record.id);
|
|
|
|
// Update the data directly or reload them
|
|
if (shouldReload) {
|
|
self._fetchRecord(record).then(function (record) {
|
|
def.resolve(changedFields);
|
|
});
|
|
} else {
|
|
_.extend(record.data, _changes);
|
|
def.resolve(changedFields);
|
|
}
|
|
}).fail(def.reject.bind(def));
|
|
} else {
|
|
def.resolve(changedFields);
|
|
}
|
|
return def;
|
|
});
|
|
},
|
|
/**
|
|
* 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 = _.extend({}, record.fields, viewInfo.fields);
|
|
// complete the given fieldsInfo with the fields of the main view, so
|
|
// that those field will be reloaded if a reload is triggered by the
|
|
// secondary view
|
|
var fieldsInfo = _.mapObject(viewInfo.fieldsInfo, function (fieldsInfo) {
|
|
return _.defaults({}, fieldsInfo, record.fieldsInfo[record.viewType]);
|
|
});
|
|
record.fieldsInfo = _.extend({}, record.fieldsInfo, fieldsInfo);
|
|
},
|
|
/**
|
|
* 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;
|
|
},
|
|
/**
|
|
* 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 {Deferred}
|
|
*/
|
|
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 $.when();
|
|
},
|
|
/**
|
|
* 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 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 $.when(groupId);
|
|
}
|
|
if (!group.isOpen) {
|
|
group.isOpen = true;
|
|
var def;
|
|
if (group.count > 0) {
|
|
def = this._load(group).then(function () {
|
|
self._updateParentResIDs(group);
|
|
});
|
|
}
|
|
return $.when(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
|
|
* @returns {Deferred<string>} resolves to the new record id
|
|
*/
|
|
_addX2ManyDefaultRecord: function (list, options) {
|
|
var self = this;
|
|
var params = {
|
|
context: this._getContext(list),
|
|
fields: list.fields,
|
|
fieldsInfo: list.fieldsInfo,
|
|
parentID: list.id,
|
|
viewType: list.viewType,
|
|
};
|
|
return this._makeDefaultRecord(list.model, params).then(function (id) {
|
|
var position = options && options.position || 'top';
|
|
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 id;
|
|
});
|
|
},
|
|
/**
|
|
* 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
|
|
* @returns {Deferred} 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.type === 'one2many' || field.type === 'many2many') {
|
|
defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options.viewType));
|
|
} else if (field.type === 'many2one' || field.type === 'reference') {
|
|
defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName]));
|
|
} else {
|
|
record._changes[fieldName] = changes[fieldName];
|
|
}
|
|
}
|
|
|
|
if (options.notifyChange === false) {
|
|
return $.Deferred().resolve(_.keys(changes));
|
|
}
|
|
|
|
return $.when.apply($, 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.onChange) {
|
|
var isX2Many = field.type === 'one2many' || field.type === 'many2many';
|
|
if (!isX2Many || (self._isX2ManyValid(record._changes[fieldName] || record.data[fieldName]))) {
|
|
onChangeFields.push(fieldName);
|
|
}
|
|
}
|
|
}
|
|
var onchangeDef = $.Deferred();
|
|
if (onChangeFields.length) {
|
|
self._performOnChange(record, onChangeFields, options.viewType)
|
|
.then(function (result) {
|
|
delete record._warning;
|
|
onchangeDef.resolve(_.keys(changes).concat(Object.keys(result && result.value || {})));
|
|
}).fail(function () {
|
|
self._visitChildren(record, function (elem) {
|
|
_.extend(elem, initialData[elem.id]);
|
|
});
|
|
onchangeDef.resolve({});
|
|
});
|
|
} 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];
|
|
record._isDirty = !_.isEmpty(record._changes);
|
|
}
|
|
});
|
|
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.
|
|
*
|
|
* @param {Object} record
|
|
* @param {string} fieldName
|
|
* @param {Object} [data]
|
|
* @returns {Deferred}
|
|
*/
|
|
_applyX2OneChange: function (record, fieldName, data) {
|
|
var self = this;
|
|
if (!data || !data.id) {
|
|
record._changes[fieldName] = false;
|
|
return $.when();
|
|
}
|
|
|
|
// 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 $.when();
|
|
}
|
|
var rel_data = _.pick(data, 'id', 'display_name');
|
|
var field = record.fields[fieldName];
|
|
|
|
// 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 coModel = field.type === 'reference' ? data.model : field.relation;
|
|
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 $.when(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 {string} [viewType] current viewType. If not set, we will assume
|
|
* main viewType from the record
|
|
* @returns {Deferred}
|
|
*/
|
|
_applyOnChange: function (values, record, viewType) {
|
|
var self = this;
|
|
var defs = [];
|
|
var rec;
|
|
viewType = viewType || record.viewType;
|
|
record._changes = record._changes || {};
|
|
_.each(values, function (val, 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;
|
|
return;
|
|
}
|
|
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) {
|
|
return; // ignore changes of x2many not in view
|
|
}
|
|
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.res_id === 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;
|
|
}
|
|
// 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));
|
|
} 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 $.when(x2ManysDef, referencesDef);
|
|
});
|
|
defs.push(def);
|
|
} else {
|
|
var newValue = self._parseServerValue(field, val);
|
|
if (newValue !== oldValue) {
|
|
record._changes[name] = newValue;
|
|
}
|
|
}
|
|
});
|
|
return $.when.apply($, 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 {string} [viewType] current viewType. If not set, we will assume
|
|
* main viewType from the record
|
|
* @returns {Deferred}
|
|
*/
|
|
_applyX2ManyChange: function (record, fieldName, command, viewType) {
|
|
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 $.when();
|
|
}
|
|
|
|
var self = this;
|
|
var localID = (record._changes && record._changes[fieldName]) || record.data[fieldName];
|
|
var list = this.localData[localID];
|
|
var field = record.fields[fieldName];
|
|
var fieldInfo = record.fieldsInfo[viewType || record.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];
|
|
|
|
// 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,
|
|
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: record.context,
|
|
}).then(function (records) {
|
|
_.each(records, function (record) {
|
|
list_records[record.id].data = record;
|
|
self._parseServerData(fieldNames, list, record);
|
|
});
|
|
return $.when(
|
|
self._fetchX2ManysBatched(list),
|
|
self._fetchReferencesBatched(list)
|
|
);
|
|
});
|
|
defs.push(def);
|
|
}
|
|
break;
|
|
case 'CREATE':
|
|
var options = {position: command.position};
|
|
def = this._addX2ManyDefaultRecord(list, options).then(function (id) {
|
|
if (command.position === 'bottom' && 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));
|
|
}
|
|
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 '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
|
|
});
|
|
}
|
|
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;
|
|
}),
|
|
});
|
|
}
|
|
return $.when(addDef, removedDef);
|
|
}
|
|
|
|
return $.when.apply($, 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--;
|
|
list.res_ids = _.without(list.res_ids, relRecord.res_id);
|
|
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"}
|
|
*
|
|
* @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|false} an onchange spec, or false if no onchange should
|
|
* be applied
|
|
*/
|
|
_buildOnchangeSpecs: function (record, viewType) {
|
|
var hasOnchange = false;
|
|
var specs = {};
|
|
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;
|
|
specs[key] = (field.onChange) || "";
|
|
if (field.onChange) {
|
|
hasOnchange = true;
|
|
}
|
|
_.each(fieldInfo.views, function (view) {
|
|
generateSpecs(view.fieldsInfo[view.type], view.fields, key + '.');
|
|
});
|
|
});
|
|
}
|
|
return hasOnchange ? specs : false;
|
|
},
|
|
/**
|
|
* Evaluate modifiers
|
|
*
|
|
* @private
|
|
* @param {Object} element a valid element object, which will serve as eval
|
|
* context.
|
|
* @param {Object} modifiers
|
|
* @returns {Object}
|
|
*/
|
|
_evalModifiers: function (element, modifiers) {
|
|
var result = {};
|
|
var self = this;
|
|
var evalContext;
|
|
function evalModifier(mod) {
|
|
if (mod === undefined || mod === false || mod === true) {
|
|
return !!mod;
|
|
}
|
|
evalContext = evalContext || self._getEvalContext(element);
|
|
return new Domain(mod, evalContext).compute(evalContext);
|
|
}
|
|
if ('invisible' in modifiers) {
|
|
result.invisible = evalModifier(modifiers.invisible);
|
|
}
|
|
if ('column_invisible' in modifiers) {
|
|
result.column_invisible = evalModifier(modifiers.column_invisible);
|
|
}
|
|
if ('readonly' in modifiers) {
|
|
result.readonly = evalModifier(modifiers.readonly);
|
|
}
|
|
if ('required' in modifiers) {
|
|
result.required = evalModifier(modifiers.required);
|
|
}
|
|
return result;
|
|
},
|
|
/**
|
|
* Fetch all name_gets for the many2ones in a group
|
|
*
|
|
* @param {Object[]} groups a list of object with context and record sub keys
|
|
* @returns {Deferred}
|
|
*/
|
|
_fetchMany2OneGroup: function (groups) {
|
|
var ids = _.uniq(_.pluck(_.pluck(groups, 'record'), 'res_id'));
|
|
return this._rpc({
|
|
model: groups[0].record.model,
|
|
method: 'name_get',
|
|
args: [ids],
|
|
context: groups[0].context
|
|
})
|
|
.then(function (name_gets) {
|
|
_.each(groups, function (obj) {
|
|
var nameGet = _.find(name_gets, function (n) { return n[0] === obj.record.res_id;});
|
|
obj.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 = [];
|
|
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);
|
|
model = many2oneRecord.model;
|
|
});
|
|
if (!ids.length) {
|
|
return $.when();
|
|
}
|
|
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} [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 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({}, record.getContext(), {bin_size: true}),
|
|
})
|
|
.then(function (result) {
|
|
if (result.length === 0) {
|
|
return $.Deferred().reject();
|
|
}
|
|
result = result[0];
|
|
record.data = _.extend({}, record.data, result);
|
|
})
|
|
.then(function () {
|
|
self._parseServerData(fieldNames, record, record.data);
|
|
})
|
|
.then(function () {
|
|
return $.when(
|
|
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 {Deferred}
|
|
*/
|
|
_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 $.when(def);
|
|
},
|
|
/**
|
|
* Fetch the extra data (`name_get`) for the reference fields of the record
|
|
* model.
|
|
*
|
|
* @private
|
|
* @param {Object} record
|
|
* @returns {Deferred}
|
|
*/
|
|
_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 $.when.apply($, 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 {Deferred}
|
|
*/
|
|
_fetchReferenceBatched: function (list, fieldName) {
|
|
var self = this;
|
|
list = this._applyX2ManyOperations(list);
|
|
|
|
// collect ids by model
|
|
var toFetch = {};
|
|
_.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];
|
|
}
|
|
}
|
|
});
|
|
|
|
var defs = [];
|
|
var def;
|
|
// one name_get by model
|
|
_.each(toFetch, function (datapoints, model) {
|
|
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];
|
|
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,
|
|
});
|
|
parent.data[fieldName] = referenceDp.id;
|
|
});
|
|
});
|
|
});
|
|
defs.push(def);
|
|
});
|
|
|
|
return $.when.apply($, defs);
|
|
},
|
|
/**
|
|
* Batch requests for references for datapoint of type list.
|
|
*
|
|
* @param {Object} list
|
|
* @returns {Deferred}
|
|
*/
|
|
_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 $.when.apply($, defs);
|
|
},
|
|
/**
|
|
* 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' && !record.fieldsInfo[record.viewType][name].__no_fetch) {
|
|
var localId = (record._changes && record._changes[name]) || record.data[name];
|
|
var relatedRecord = self.localData[localId];
|
|
if (!relatedRecord) {
|
|
return;
|
|
}
|
|
toBeFetched.push({
|
|
context: record.getContext({fieldName: name, viewType: record.viewType}),
|
|
record: 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 (elem) {
|
|
return [elem.record.model, JSON.stringify(elem.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
|
|
* @param {Object} options
|
|
* @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, options) {
|
|
var self = this;
|
|
var specialFieldNames = [];
|
|
var fieldNames = (options && options.fieldNames) || record.getFieldNames();
|
|
return $.when.apply($, _.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 {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, fieldInfo, 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 && 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 $.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 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 {Deferred}
|
|
*/
|
|
_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 $.when(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 {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, 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 {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, fieldInfo) {
|
|
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 $.when();
|
|
} else if (!domainModel) {
|
|
return $.when({
|
|
model: domainModel,
|
|
nbRecords: 0,
|
|
});
|
|
}
|
|
|
|
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).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 $.when.apply($, defs);
|
|
}
|
|
});
|
|
} else {
|
|
def = this._searchReadUngroupedList(list);
|
|
}
|
|
return def.then(function () {
|
|
return $.when(
|
|
self._fetchX2ManysBatched(list),
|
|
self._fetchReferencesBatched(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 = [];
|
|
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: record.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,
|
|
});
|
|
// set existing changes to the list
|
|
if (record._changes && record._changes[fieldName]) {
|
|
list._changes = self.localData[record._changes[fieldName]]._changes;
|
|
record._changes[fieldName] = list.id;
|
|
}
|
|
record.data[fieldName] = list.id;
|
|
if (!fieldInfo.__no_fetch) {
|
|
var def = self._readUngroupedList(list).then(function () {
|
|
return $.when(
|
|
self._fetchX2ManysBatched(list),
|
|
self._fetchReferencesBatched(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];
|
|
var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo;
|
|
var fields = view ? view.fields : fieldInfo.relatedFields;
|
|
var viewType = view ? view.type : fieldInfo.viewType;
|
|
list = this._applyX2ManyOperations(list);
|
|
this._sortList(list);
|
|
var x2mRecords = [];
|
|
|
|
// step 1: collect ids
|
|
var ids = [];
|
|
_.each(list.data, 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;
|
|
}
|
|
x2mRecords.push(record);
|
|
ids = _.unique(ids.concat(record.data[fieldName] || []));
|
|
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;
|
|
});
|
|
|
|
if (!ids.length || fieldInfo.__no_fetch) {
|
|
return $.when();
|
|
}
|
|
var def;
|
|
var fieldNames = _.keys(fieldInfo.relatedFields);
|
|
// step 2: fetch data from server
|
|
// if we want specific fields
|
|
// if not we return an array of objects with the id
|
|
// to avoid fetching all the relation fields and an useless rpc
|
|
if (fieldNames.length) {
|
|
def = this._rpc({
|
|
model: field.relation,
|
|
method: 'read',
|
|
args: [ids, fieldNames],
|
|
context: list.getContext() || {},
|
|
});
|
|
} else {
|
|
def = $.when(_.map(ids, function (id) {
|
|
return {id:id};
|
|
}));
|
|
}
|
|
return def.then(function (results) {
|
|
// step 3: assign values to correct datapoints
|
|
_.each(x2mRecords, function (record) {
|
|
var m2mList = self.localData[record.data[fieldName]];
|
|
m2mList.data = [];
|
|
_.each(m2mList.res_ids, function (res_id) {
|
|
var dataPoint = self._makeDataPoint({
|
|
modelName: field.relation,
|
|
data: _.findWhere(results, {id: res_id}),
|
|
fields: fields,
|
|
fieldsInfo: fieldsInfo,
|
|
parentID: m2mList.id,
|
|
viewType: viewType,
|
|
});
|
|
m2mList.data.push(dataPoint.id);
|
|
m2mList._cache[res_id] = dataPoint.id;
|
|
});
|
|
});
|
|
});
|
|
},
|
|
/**
|
|
* 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
|
|
* @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;
|
|
if ('changesOnly' in options && !options.changesOnly) {
|
|
changes = _.extend({}, record.data, record._changes);
|
|
} else {
|
|
changes = _.extend({}, record._changes);
|
|
}
|
|
var withReadonly = options.withReadonly || false;
|
|
var commands = this._generateX2ManyCommands(record, {
|
|
changesOnly: 'changesOnly' in options ? options.changesOnly : true,
|
|
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 (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 commands = this._generateX2ManyCommands(record, options);
|
|
var 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 (changes.id) {
|
|
// the subrecord already exists in db
|
|
delete changes.id;
|
|
if (this.isNew(record.id)) {
|
|
// if the main record is new, link the subrecord to it
|
|
commands[fieldName].push(x2ManyCommands.link_to(relRecord.res_id));
|
|
}
|
|
if (!_.isEmpty(changes)) {
|
|
commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, changes));
|
|
}
|
|
} else {
|
|
// the subrecord is new, so create it
|
|
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();
|
|
},
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
return _.extend({
|
|
active_id: evalContext.id || false,
|
|
active_ids: evalContext.id ? [evalContext.id] : [],
|
|
active_model: element.model,
|
|
current_date: moment().format('YYYY-MM-DD'),
|
|
id: evalContext.id || false,
|
|
}, 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;
|
|
},
|
|
/**
|
|
* 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) {
|
|
var fieldInfo = record.fieldsInfo &&
|
|
(record.fieldsInfo[viewType || record.viewType][fieldName]);
|
|
if (fieldInfo) {
|
|
var rawModifiers = fieldInfo.modifiers || {};
|
|
var modifiers = this._evalModifiers(record, rawModifiers);
|
|
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 === '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, rawModifiers);
|
|
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 {Deferred}
|
|
*/
|
|
_load: function (dataPoint, options) {
|
|
if (options && options.onlyGroups &&
|
|
!(dataPoint.type === 'list' && dataPoint.groupedBy.length)) {
|
|
return $.when(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);
|
|
}
|
|
},
|
|
/**
|
|
* 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' ? {} : []);
|
|
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_');
|
|
}
|
|
} 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: params.context,
|
|
count: params.count || res_ids.length,
|
|
data: data,
|
|
domain: params.domain || [],
|
|
fields: fields,
|
|
fieldsInfo: params.fieldsInfo,
|
|
groupedBy: params.groupedBy || [],
|
|
id: _.uniqueId(params.modelName + '_'),
|
|
isOpen: params.isOpen,
|
|
limit: type === 'record' ? 1 : params.limit,
|
|
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);
|
|
|
|
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 {Deferred<string>} resolves to the id for the created resource
|
|
*/
|
|
_makeDefaultRecord: function (modelName, params) {
|
|
var self = this;
|
|
|
|
var determineExtraFields = function() {
|
|
// 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 = self.localData[params.parentID];
|
|
|
|
var originView = parentRecord && parentRecord.fieldsInfo;
|
|
if (!originView || !originView[parentRecord.viewType])
|
|
return [];
|
|
|
|
var fieldsFromOrigin = _.filter(Object.keys(originView[parentRecord.viewType]),
|
|
function(fieldname) {
|
|
return params.fields[fieldname] !== undefined;
|
|
});
|
|
|
|
return fieldsFromOrigin;
|
|
}
|
|
|
|
var fieldNames = Object.keys(params.fieldsInfo[params.viewType]);
|
|
var fields_key = _.without(fieldNames, '__last_update');
|
|
|
|
var extraFields = determineExtraFields();
|
|
|
|
return this._rpc({
|
|
model: modelName,
|
|
method: 'default_get',
|
|
args: [fields_key],
|
|
context: params.context,
|
|
})
|
|
.then(function (result) {
|
|
var record = self._makeDataPoint({
|
|
modelName: modelName,
|
|
fields: params.fields,
|
|
fieldsInfo: params.fieldsInfo,
|
|
context: params.context,
|
|
parentID: params.parentID,
|
|
res_ids: params.res_ids,
|
|
viewType: params.viewType,
|
|
});
|
|
|
|
return self.applyDefaultValues(record.id, result, {fieldNames: _.union(fieldNames, extraFields)})
|
|
.then(function () {
|
|
var def = $.Deferred();
|
|
self._performOnChange(record, fields_key).always(function () {
|
|
if (record._warning) {
|
|
if (params.allowWarning) {
|
|
delete record._warning;
|
|
} else {
|
|
def.reject();
|
|
}
|
|
}
|
|
def.resolve();
|
|
});
|
|
return def;
|
|
})
|
|
.then(function () {
|
|
return self._fetchRelationalData(record);
|
|
})
|
|
.then(function () {
|
|
return self._postprocess(record);
|
|
})
|
|
.then(function () {
|
|
// save initial changes, so they can be restored later,
|
|
// if we need to discard.
|
|
self.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
|
|
* @param {string} [viewType] current viewType. If not set, we will assume
|
|
* main viewType from the record
|
|
* @returns {Deferred}
|
|
*/
|
|
_performOnChange: function (record, fields, viewType) {
|
|
var self = this;
|
|
var onchangeSpec = this._buildOnchangeSpecs(record, viewType);
|
|
if (!onchangeSpec) {
|
|
return $.when();
|
|
}
|
|
var idList = record.data.id ? [record.data.id] : [];
|
|
var options = {
|
|
full: true,
|
|
};
|
|
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, {changesOnly: false});
|
|
|
|
return self._rpc({
|
|
model: record.model,
|
|
method: 'onchange',
|
|
args: [idList, currentData, fields, onchangeSpec, 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;
|
|
}
|
|
if (result.domain) {
|
|
record._domains = _.extend(record._domains, result.domain);
|
|
}
|
|
return self._applyOnChange(result.value, record).then(function () {
|
|
return result;
|
|
});
|
|
});
|
|
},
|
|
/**
|
|
* 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 {Deferred<Object>} resolves to the finished resource
|
|
*/
|
|
_postprocess: function (record, options) {
|
|
var self = this;
|
|
var defs = [];
|
|
|
|
_.each(record.getFieldNames(options), 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, options));
|
|
|
|
return $.when.apply($, 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 {Deferred}
|
|
*/
|
|
_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;
|
|
|
|
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[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 $.when(
|
|
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 $.when.apply($, 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 {Deferred<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._rpc({
|
|
model: list.model,
|
|
method: 'read',
|
|
args: [missingIDs, fieldNames],
|
|
context: list.getContext(),
|
|
});
|
|
} else {
|
|
def = $.when(_.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.context,
|
|
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 {Deferred<Object>} resolves to the fetched group object
|
|
*/
|
|
_readGroup: function (list, options) {
|
|
var self = this;
|
|
var groupByField = list.groupedBy[0];
|
|
var rawGroupBy = groupByField.split(':')[0];
|
|
var fields = _.uniq(list.getFieldNames().concat(rawGroupBy));
|
|
return this._rpc({
|
|
model: list.model,
|
|
method: 'read_group',
|
|
fields: fields,
|
|
domain: list.domain,
|
|
context: list.context,
|
|
groupBy: list.groupedBy,
|
|
orderBy: list.orderedBy,
|
|
lazy: true,
|
|
})
|
|
.then(function (groups) {
|
|
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 !== groupByField) {
|
|
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) {
|
|
// restore the internal state of the group
|
|
delete self.localData[newGroup.id];
|
|
var updatedProps = _.omit(newGroup, 'limit', 'isOpen', 'offset', 'id');
|
|
if (options && 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
|
|
delete updatedProps.data;
|
|
}
|
|
_.extend(oldGroup, updatedProps);
|
|
newGroup = oldGroup;
|
|
} else if (!newGroup.openGroupByDefault) {
|
|
newGroup.isOpen = false;
|
|
} else {
|
|
newGroup.isOpen = '__fold' in group ? !group.__fold : true;
|
|
}
|
|
list.data.push(newGroup.id);
|
|
list.count += newGroup.count;
|
|
if (newGroup.isOpen && newGroup.count > 0) {
|
|
defs.push(self._load(newGroup, options));
|
|
}
|
|
});
|
|
if (options && 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 $.when.apply($, defs).then(function () {
|
|
if (!options || !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(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 = $.when();
|
|
|
|
// 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 = [];
|
|
// generate the current count and res_ids list by applying the changes
|
|
var currentCount = list.count;
|
|
var currentResIDs = list.res_ids;
|
|
var effectiveLimit = (list.limit || 0) - (list.tempLimitIncrement || 0);
|
|
var upperBound = effectiveLimit ? Math.min(list.offset + effectiveLimit, 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;
|
|
});
|
|
});
|
|
},
|
|
/**
|
|
* 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,
|
|
context: list.getContext(),
|
|
domain: list.domain || [],
|
|
limit: list.limit,
|
|
offset: list.loadMoreOffset + list.offset,
|
|
orderBy: list.orderedBy,
|
|
})
|
|
.then(function (result) {
|
|
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(record1ID, record2ID, 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. 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;
|
|
});
|