diff --git a/addons/web/static/src/js/chrome/pager.js b/addons/web/static/src/js/chrome/pager.js
index cd16382be0e..bbe69c6d86a 100644
--- a/addons/web/static/src/js/chrome/pager.js
+++ b/addons/web/static/src/js/chrome/pager.js
@@ -144,7 +144,7 @@ var Pager = Widget.extend({
_edit: function () {
if (this.options.can_edit) {
var self = this;
- var $input = $('', {type: 'text', value: this.$value.html()});
+ var $input = $('', {class: 'o_input', type: 'text', value: this.$value.html()});
this.$value.html($input);
$input.focus();
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 419d1f5f646..5b9114b6c82 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -657,7 +657,14 @@ var FieldX2Many = AbstractField.extend({
reset: function (record, ev) {
if (ev && ev.target === this && ev.data.changes && this.view.arch.tag === 'tree') {
var command = ev.data.changes[this.name];
- if (command.operation === 'UPDATE') {
+ // Here, we only consider 'UPDATE' commands with data, which occur
+ // with editable list view. In order to keep the current line in
+ // edition, we call confirmChange which will reset the widgets
+ // instead of re-rendering the list. 'UPDATE' commands with no data
+ // can be ignored: they occur in one2manys when the record is
+ // updated from a dialog and in this case, we can re-render the
+ // whole subview.
+ if (command.operation === 'UPDATE' && command.data) {
var state = record.data[this.name];
var fieldNames = state.getFieldNames();
this.renderer.confirmChange(state, command.id, fieldNames, ev.initialEvent);
@@ -1068,9 +1075,15 @@ var FieldOne2Many = FieldX2Many.extend({
// we don't want interference with the components upstream.
ev.stopPropagation();
+ var id = ev.data.id;
+ // trigger an empty 'UPDATE' operation when the user clicks on 'Save' in
+ // the dialog, to notify the main record that a subrecord of this
+ // relational field has changed (those changes will be already stored on
+ // that subrecord, thanks to the 'Save').
+ var onSaved = this._setValue.bind(this, { operation: 'UPDATE', id: id }, {});
this._openFormDialog({
- id: ev.data.id,
- on_saved: this._setValue.bind(this, { operation: 'NOOP' }, {}),
+ id: id,
+ on_saved: onSaved,
readonly: this.mode === 'readonly',
});
},
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index 716624f368b..7e4c5927503 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -146,8 +146,11 @@ var BasicModel = AbstractModel.extend({
//--------------------------------------------------------------------------
/**
- * Add a default record to a list object. This method actually make a new
- * record with the _makeDefaultRecord method, then add it to the list object.
+ * 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]
@@ -156,6 +159,7 @@ var BasicModel = AbstractModel.extend({
* @returns {Deferred} 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);
@@ -169,12 +173,13 @@ var BasicModel = AbstractModel.extend({
};
return this._makeDefaultRecord(list.model, params).then(function (id) {
list.count++;
- list._changes = list._changes || list.data.slice(0);
if (position === 'top') {
- list._changes.unshift(id);
+ list.data.unshift(id);
} else {
- list._changes.push(id);
+ list.data.push(id);
}
+ var record = self.localData[id];
+ list._cache[record.res_id] = id;
return id;
});
},
@@ -331,11 +336,8 @@ var BasicModel = AbstractModel.extend({
if (field.type === 'one2many' || field.type === 'many2many') {
if (options.raw) {
relDataPoint = this.localData[data[fieldName]];
- var relData = relDataPoint._changes || relDataPoint.data;
- var ids = _.map(relData, function (id) {
- return self.localData[id].res_id;
- });
- data[fieldName] = ids;
+ relDataPoint = this._applyX2ManyOperations(relDataPoint);
+ data[fieldName] = relDataPoint.res_ids;
} else {
data[fieldName] = this.get(data[fieldName]) || [];
}
@@ -376,25 +378,15 @@ var BasicModel = AbstractModel.extend({
}
// here, type === 'list'
- var listData, listCount, resIDs;
- if (element._changes) {
- listData = element._changes;
- listCount = listData.length;
- resIDs = _.map(listData, function (elemID) {
- return self.localData[elemID].res_id;
- });
- } else {
- listData = element.data;
- listCount = element.count;
- resIDs = element.res_ids;
- }
- listData = _.map(listData, function (elemID) {
+ // apply potential changes (only for x2many lists)
+ element = this._applyX2ManyOperations(element);
+ var listData = _.map(element.data, function (elemID) {
return self.get(elemID, options);
});
var list = {
aggregateValues: _.extend({}, element.aggregateValues),
context: _.extend({}, element.context),
- count: listCount,
+ count: element.count,
data: listData,
domain: element.domain.slice(0),
fields: element.fields,
@@ -409,7 +401,7 @@ var BasicModel = AbstractModel.extend({
offset: element.offset,
orderedBy: element.orderedBy,
res_id: element.res_id,
- res_ids: resIDs,
+ res_ids: element.res_ids,
type: 'list',
value: element.value,
viewType: element.viewType,
@@ -417,6 +409,7 @@ var BasicModel = AbstractModel.extend({
if (element.fieldsInfo) {
list.fieldsInfo = element.fieldsInfo;
}
+ this._sortList(list);
return list;
},
/**
@@ -538,6 +531,7 @@ var BasicModel = AbstractModel.extend({
id: id,
display_name: display_name,
},
+ parentID: record.id,
});
record.data[field.name] = dataPoint.id;
if (display_name === undefined) {
@@ -553,6 +547,7 @@ var BasicModel = AbstractModel.extend({
var dpParams = {
fieldsInfo: relatedFieldsInfo,
modelName: field.relation,
+ parentID: record.id,
static: true,
type: 'list',
viewType: 'default',
@@ -570,9 +565,11 @@ var BasicModel = AbstractModel.extend({
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;
@@ -689,7 +686,16 @@ var BasicModel = AbstractModel.extend({
removeLine: function (elementID) {
var record = this.localData[elementID];
var parent = this.localData[record.parentID];
- parent._changes = _.without(parent._changes, elementID);
+ if (parent.parentID) {
+ // 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) {
+ return change.id !== elementID;
+ });
+ } else {
+ // main list view case: the new record is in data
+ parent.data = _.without(parent.data, elementID);
+ }
},
/**
* Save a local resource, if needed. This is a complicated operation,
@@ -809,13 +815,7 @@ var BasicModel = AbstractModel.extend({
record.fieldsInfo = _.defaults(record.fieldsInfo, viewInfo.fieldsInfo);
},
/**
- * For list resources, this changes the orderedBy key, then performs the
- * sort directly, in javascript. This is used for sorting static datasets,
- * such as a one2many in a form view. For dynamic datasets, such as a list
- * view, this method will be called, but then the sort will be ignored since
- * we will reload data.
- *
- * @todo don't sort in js when we reload data anyway
+ * For list resources, this changes the orderedBy key.
*
* @param {string} list_id id for the list resource
* @param {string} fieldName valid field name
@@ -837,7 +837,6 @@ var BasicModel = AbstractModel.extend({
});
list.orderedBy = [{name: fieldName, asc: true}].concat(orderedBy);
}
- this._sortList(list);
return this;
},
/**
@@ -893,6 +892,36 @@ var BasicModel = AbstractModel.extend({
// 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 {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 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});
+ var record = self.localData[id];
+ list._cache[record.res_id] = id;
+ return id;
+ });
+ },
/**
* This method is the private version of notifyChanges. Unlike
* notifyChanges, it is not protected by a mutex. Every changes from the
@@ -1014,12 +1043,13 @@ var BasicModel = AbstractModel.extend({
fields: {},
fieldsInfo: {},
modelName: record.fields[fieldName].relation,
+ parentID: record.id,
});
record._changes[fieldName] = rec.id;
});
},
/**
- * Applies the result on an onchange RPC on a record.
+ * Applies the result of an onchange RPC on a record.
*
* @private
* @param {Object} values the result of the onchange RPC (a mapping of
@@ -1053,6 +1083,7 @@ var BasicModel = AbstractModel.extend({
context: record.context,
data: data,
modelName: field.relation,
+ parentID: record.id,
});
id = rec.id;
}
@@ -1070,12 +1101,15 @@ var BasicModel = AbstractModel.extend({
list = self._makeDataPoint({
fieldsInfo: fieldInfo.fieldsInfo,
modelName: field.relation,
+ parentID: record.id,
+ static: true,
type: 'list',
viewtype: fieldInfo.viewType,
});
}
record._changes[name] = list.id;
var shouldLoad = false;
+ list._changes = list._changes || [];
_.each(val, function (command) {
if (command[0] === 0 || command[0] === 1) {
// CREATE or UPDATE
@@ -1091,23 +1125,25 @@ var BasicModel = AbstractModel.extend({
params.res_id = command[1];
}
rec = self._makeDataPoint(params);
- list._changes.push(rec.id);
+ list._cache[rec.res_id] = rec.id;
+ 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
- list.res_ids.push(command[1]);
- list.count++;
+ // the dataPoint id will be set when the record will be fetched (for now,
+ // this dataPoint doesn't exist yet)
+ list._changes.push({operation: 'ADD', id: null, resID: command[1]});
shouldLoad = true;
} else if (command[0] === 5) {
// DELETE ALL
- list._changes = [];
+ list._changes = [{operation: 'REMOVE_ALL'}];
}
});
if (shouldLoad) {
- var def = self._readUngroupedList(list).then(function () {
- list._changes = list.data;
- });
- defs.push(def);
+ defs.push(self._readUngroupedList(list));
}
} else {
record._changes[name] = self._parseServerValue(field, val);
@@ -1143,9 +1179,9 @@ var BasicModel = AbstractModel.extend({
var field = record.fields[fieldName];
var fieldInfo = record.fieldsInfo[viewType || record.viewType][fieldName];
var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
- var rec;
+ var def, rec;
var defs = [];
- list._changes = list._changes || list.data.slice(0);
+ list._changes = list._changes || [];
switch (command.operation) {
case 'ADD':
@@ -1163,8 +1199,8 @@ var BasicModel = AbstractModel.extend({
newRecord.fields = list.fields;
newRecord.fieldsInfo = list.fieldsInfo;
newRecord.viewType = list.viewType;
- list._changes.push(newRecord.id);
- this._sortList(list);
+ list._cache[newRecord.res_id] = newRecord.id;
+ list._changes.push(command);
break;
case 'ADD_M2M':
// force to use link command instead of create command
@@ -1184,8 +1220,8 @@ var BasicModel = AbstractModel.extend({
parentID: list.id,
});
list_records[d.id] = rec;
- list._changes.push(rec.id);
- self._sortList(list);
+ 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
@@ -1194,14 +1230,14 @@ var BasicModel = AbstractModel.extend({
// so that if a record is removed and then re-added, it won't be fetched twice
var fieldNames = list.getFieldNames();
if (fieldNames.length) {
- var def = this._rpc({
+ def = this._rpc({
model: list.model,
method: 'read',
args: [_.pluck(data, 'id'), fieldNames]
}).then(function (records) {
_.each(records, function (record) {
list_records[record.id].data = record;
- self._parseServerData(fieldNames, list.fields, record);
+ self._parseServerData(fieldNames, list, record);
});
return self._fetchX2ManysBatched(list);
});
@@ -1209,29 +1245,44 @@ var BasicModel = AbstractModel.extend({
}
break;
case 'CREATE':
- if (command.data) {
- defs.push(this.addDefaultRecord(list.id).then(function (id) {
+ var options = {position: command.position};
+ def = this._addX2ManyDefaultRecord(list, options).then(function (id) {
+ // FIXME: hack for lunch widget, which does useless default_get and onchange
+ if (command.data) {
return self._applyChange(id, command.data);
- }));
- } else {
- defs.push(this.addDefaultRecord(list.id, {position: command.position}));
- }
+ }
+ });
+ defs.push(def);
break;
case 'UPDATE':
- defs.push(this._applyChange(command.id, command.data));
+ list._changes.push({operation: 'UPDATE', id: command.id});
+ if (command.data) {
+ defs.push(this._applyChange(command.id, command.data));
+ }
break;
case 'REMOVE':
- list._changes = _.difference(list._changes, command.ids);
+ // filter out existing operations involving the current
+ // dataPoint, and add a 'REMOVE' 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) {
+ var idInCommands = _.contains(command.ids, change.id);
+ if (idInCommands && change.operation === 'ADD') {
+ idsToRemove = _.without(idsToRemove, change.id);
+ }
+ return idInCommands;
+ });
+ _.each(idsToRemove, function (id) {
+ list._changes.push({operation: 'REMOVE', id: id});
+ });
break;
case 'REPLACE_WITH':
- // this is certainFly not optimal... and not sure that it is
+ // this is certainly not optimal... and not sure that it is
// correct if some ids are added and some other are removed
- var currentData = _.map(list._changes, function (localId) {
- return self.localData[localId];
- });
- var currentIds = _.pluck(currentData, 'res_id');
- var newIds = _.difference(command.ids, currentIds);
- var removedIds = _.difference(currentIds, command.ids);
+ 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) {
@@ -1243,18 +1294,87 @@ var BasicModel = AbstractModel.extend({
});
}
if (removedIds.length) {
- values = _.filter(currentData, function (dataPoint) {
- return _.contains(removedIds, dataPoint.res_id);
+ var listData = _.map(list.data, function (localId) {
+ return self.localData[localId];
});
removedDef = this._applyX2ManyChange(record, fieldName, {
operation: 'REMOVE',
- ids: _.pluck(values, 'id'),
+ ids: _.map(removedIds, function (resID) {
+ return _.findWhere(listData, {res_id: resID}).id;
+ }),
});
}
return $.when(addDef, removedDef);
}
- return $.when.apply($, defs);
+ 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);
+ });
+ },
+ /**
+ * In dataPoints of type list for x2manys, the changes are stored as a list
+ * of operations (being of type 'ADD', 'REMOVE', '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
+ * @returns {Object} element of type list in which the commands have been
+ * applied
+ */
+ _applyX2ManyOperations: function (list) {
+ if (!list.static) {
+ // this function only applies on x2many lists
+ return list;
+ }
+ var self = this;
+ list = _.extend({}, list);
+ list.data = list.data.slice(0);
+ list.res_ids = list.res_ids.slice(0);
+ _.each(list._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') {
+ list.res_ids.unshift(resID);
+ } else {
+ list.res_ids.push(resID);
+ }
+ break;
+ case 'REMOVE':
+ 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;
+ }
+ });
+ 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]);
+ }
+ });
+ return list;
},
/**
* Helper method to build a 'spec', that is a description of all fields in
@@ -1368,7 +1488,8 @@ var BasicModel = AbstractModel.extend({
var self = this;
var model;
var records = [];
- var ids = _.map(list._changes || list.data, function (localId) {
+ list = this._applyX2ManyOperations(list);
+ var ids = _.map(list.data, function (localId) {
var record = self.localData[localId];
var data = record._changes || record.data;
var many2oneId = data[fieldName];
@@ -1420,7 +1541,7 @@ var BasicModel = AbstractModel.extend({
record.data = _.extend({}, record.data, result);
})
.then(function () {
- self._parseServerData(fieldNames, record.fields, record.data);
+ self._parseServerData(fieldNames, record, record.data);
})
.then(function () {
return self._fetchX2Manys(record, options).then(function () {
@@ -1783,12 +1904,12 @@ var BasicModel = AbstractModel.extend({
var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo;
var fields = view ? view.fields : fieldInfo.relatedFields;
var viewType = view ? view.type : fieldInfo.viewType;
- var data = list._changes || list.data;
+ list = this._applyX2ManyOperations(list);
var x2mRecords = [];
// step 1: collect ids
var ids = [];
- _.each(data, function (dataPoint) {
+ _.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
@@ -1803,6 +1924,7 @@ var BasicModel = AbstractModel.extend({
fieldsInfo: fieldsInfo,
fields: fields,
modelName: field.relation,
+ parentID: record.id,
res_ids: record.data[fieldName],
static: true,
type: 'list',
@@ -1834,25 +1956,20 @@ var BasicModel = AbstractModel.extend({
}
return def.then(function (results) {
// step 3: assign values to correct datapoints
- var dataPoints = _.map(results, function (result) {
- return self._makeDataPoint({
- modelName: field.relation,
- data: result,
- fields: fields,
- fieldsInfo: fieldsInfo,
- viewType: viewType,
- });
- });
-
_.each(x2mRecords, function (record) {
var m2mList = self.localData[record.data[fieldName]];
-
m2mList.data = [];
_.each(m2mList.res_ids, function (res_id) {
- var dataPoint = _.find(dataPoints, function (d) {
- return d.res_id === res_id;
+ 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;
});
});
});
@@ -1961,6 +2078,8 @@ var BasicModel = AbstractModel.extend({
*
* @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
@@ -1970,11 +2089,15 @@ var BasicModel = AbstractModel.extend({
_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 record.fields) {
- type = record.fields[fieldName].type;
+ for (var fieldName in fields) {
+ type = fields[fieldName].type;
if (type === 'many2many' || type === 'one2many') {
commands[fieldName] = [];
@@ -1983,24 +2106,31 @@ var BasicModel = AbstractModel.extend({
continue;
}
var list = this.localData[data[fieldName]];
- if (options.changesOnly && !list._changes) {
+ if (options.changesOnly && (!list._changes || !list._changes.length)) {
// if only changes are requested, skip if there is no change
continue;
}
- var relData = _.map(list._changes || list.data, function (localId) {
- return self.localData[localId];
+ var oldResIDs = list.res_ids.slice(0);
+ var relRecordAdded = [];
+ var relRecordUpdated = [];
+ _.each(list._changes, function (change) {
+ if (change.operation === 'ADD') {
+ relRecordAdded.push(self.localData[change.id]);
+ } else if (change.operation === 'UPDATE') {
+ relRecordUpdated.push(self.localData[change.id]);
+ }
});
- var relIds = _.pluck(relData, 'res_id');
+ list = this._applyX2ManyOperations(list);
if (type === 'many2many' || list._forceM2MLink) {
// deliberately generate a single 'replace' command instead
// of a 'delete' and a 'link' commands with the exact diff
// because 1) performance-wise it doesn't change anything
// and 2) to guard against concurrent updates (policy: force
// a complete override of the actual value of the m2m)
- commands[fieldName].push(x2ManyCommands.replace_with(relIds));
+ commands[fieldName].push(x2ManyCommands.replace_with(list.res_ids));
// generate update commands for records that have been
// updated (it may happen with editable lists)
- _.each(relData, function (relRecord) {
+ _.each(relRecordUpdated, function (relRecord) {
var changes = self._generateChanges(relRecord, options);
if (!_.isEmpty(changes)) {
var command = x2ManyCommands.update(relRecord.res_id, changes);
@@ -2008,29 +2138,29 @@ var BasicModel = AbstractModel.extend({
}
});
} else if (type === 'one2many') {
- var removedIds = _.difference(list.res_ids, relIds);
- var addedIds = _.difference(relIds, list.res_ids);
- var keptIds = _.intersection(list.res_ids, relIds);
+ 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 < relIds.length; i++) {
- if (_.contains(keptIds, relIds[i])) {
+ 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(relData, {res_id: relIds[i]});
- changes = this._generateChanges(relRecord, options);
+ 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(relIds[i]);
+ command = x2ManyCommands.link_to(list.res_ids[i]);
}
commands[fieldName].push(command);
- } else if (_.contains(addedIds, relIds[i])) {
+ } else if (_.contains(addedIds, list.res_ids[i])) {
// this is a new id
- relRecord = _.findWhere(relData, {res_id: relIds[i]});
+ relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]});
changes = this._generateChanges(relRecord, options);
commands[fieldName].push(x2ManyCommands.create(changes));
}
@@ -2190,6 +2320,13 @@ var BasicModel = AbstractModel.extend({
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) {
@@ -2210,23 +2347,9 @@ var BasicModel = AbstractModel.extend({
continue;
}
if (field.type === 'one2many' || field.type === 'many2many') {
- relDataPoint = this.localData[context[fieldName]];
- var relData = relDataPoint._changes || relDataPoint.data;
- var ids = _.map(relData, function (id) {
- return self.localData[id].res_id;
- });
-
- ids.toJSON = function () {
- return _.map(relData, function (id) {
- var resID = self.localData[id].res_id;
- if (typeof resID === 'string') {
- var changes = self._generateChanges(self.localData[id]);
- return x2ManyCommands.create(changes);
- } else {
- return x2ManyCommands.link_to(resID);
- }
- });
- };
+ relDataPoint = this._applyX2ManyOperations(this.localData[context[fieldName]]);
+ var ids = relDataPoint.res_ids.slice(0);
+ ids.toJSON = _generateX2ManyCommands.bind(null, fieldName);
context[fieldName] = ids;
}
@@ -2285,16 +2408,19 @@ var BasicModel = AbstractModel.extend({
* an onchange.
*
* @param {string} id id for a local resource of type 'list'. This is
- * assumed to be a list element for a one2many
+ * 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 || element.data, function (recordID) {
- var recordData = self.get(recordID, {raw: true}).data;
- var record = self.localData[recordID];
+ _.each(element._changes, function (command) {
+ if (command.operation === 'REMOVE' || 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];
@@ -2477,6 +2603,7 @@ var BasicModel = AbstractModel.extend({
context: record.context,
data: {id: result[name]},
modelName: field.relation,
+ parentID: record.id,
});
record._changes[name] = rec.id;
} else if (field.type === 'one2many' || field.type === 'many2many') {
@@ -2501,6 +2628,7 @@ var BasicModel = AbstractModel.extend({
viewType: viewType,
});
record._changes[name] = x2manyList.id;
+ x2manyList._changes = [];
var many2ones = {};
var r;
var isCommandList = result[name].length && _.isArray(result[name][0]);
@@ -2516,10 +2644,11 @@ var BasicModel = AbstractModel.extend({
context: x2manyList.context,
fieldsInfo: fieldsInfo,
fields: fields,
+ parentID: x2manyList.id,
viewType: viewType,
});
- x2manyList._changes = x2manyList._changes || [];
- x2manyList._changes.push(r.id);
+ 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) {
@@ -2533,7 +2662,8 @@ var BasicModel = AbstractModel.extend({
var rec = self._makeDataPoint({
context: r.context,
modelName: r.fields[name].relation,
- data: {id: r._changes[name]}
+ data: {id: r._changes[name]},
+ parentID: r.id,
});
r._changes[name] = rec.id;
many2ones[name] = true;
@@ -2586,14 +2716,15 @@ var BasicModel = AbstractModel.extend({
* parse the server values to javascript framwork
*
* @param {[string]} fieldNames
- * @param {Object} fields
- * @param {Object} record
+ * @param {Object} element the dataPoint used as parent for the created
+ * dataPoints
+ * @param {Object} data the server data to parse
*/
- _parseServerData: function (fieldNames, fields, record) {
+ _parseServerData: function (fieldNames, element, data) {
var self = this;
_.each(fieldNames, function (fieldName) {
- var field = fields[fieldName];
- var val = record[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) {
@@ -2608,14 +2739,15 @@ var BasicModel = AbstractModel.extend({
display_name: val[1],
id: val[0],
},
+ parentID: element.id,
});
- record[fieldName] = r.id;
+ data[fieldName] = r.id;
} else {
// no value for the many2one
- record[fieldName] = false;
+ data[fieldName] = false;
}
} else {
- record[fieldName] = self._parseServerValue(field, val);
+ data[fieldName] = self._parseServerValue(field, val);
}
});
},
@@ -2843,10 +2975,14 @@ var BasicModel = AbstractModel.extend({
var def;
var ids = [];
var missingIds = [];
- var upper_bound = list.limit ? Math.min(list.offset + list.limit, list.count) : list.count;
+ // generate the current count and res_ids list by applying the changes
+ var listWithChanges = this._applyX2ManyOperations(list);
+ var currentCount = listWithChanges.count;
+ var currentResIDs = listWithChanges.res_ids;
+ var upper_bound = list.limit ? Math.min(list.offset + list.limit, currentCount) : currentCount;
var fieldNames = list.getFieldNames();
for (var i = list.offset; i < upper_bound; i++) {
- var id = list.res_ids[i];
+ var id = currentResIDs[i];
ids.push(id);
if (!list._cache[id]) {
missingIds.push(id);
@@ -2885,12 +3021,17 @@ var BasicModel = AbstractModel.extend({
});
// add many2one records
- self._parseServerData(fieldNames, dataPoint.fields, dataPoint.data);
+ self._parseServerData(fieldNames, dataPoint, dataPoint.data);
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;
+ }
+ });
list.data.push(dataPoint.id);
});
- self._sortList(list);
return list;
});
},
@@ -2947,7 +3088,7 @@ var BasicModel = AbstractModel.extend({
});
// add many2one records
- self._parseServerData(fieldNames, dataPoint.fields, dataPoint.data);
+ self._parseServerData(fieldNames, dataPoint, dataPoint.data);
return dataPoint.id;
});
if (list.loadMoreOffset) {
@@ -2978,22 +3119,20 @@ var BasicModel = AbstractModel.extend({
},
/**
* Do a in-memory sort of a list resource data points. This method assumes
- * that the list data has already been fetched. Its intended use is for
- * static datasets, such as a one2many in a form view.
+ * that the list data has already been fetched, and that the changes have
+ * been applied. Its intended use is for static datasets, such as a one2many
+ * in a form view.
*
- * @param {Object} list
+ * @param {Object} list list dataPoint on which changes have been applied
*/
_sortList: function (list) {
- var self = this;
if (list.orderedBy.length) {
// sort records according to ordered_by[0]
var order = list.orderedBy[0];
- var data = list._changes || list.data;
- data.sort(function (id1, id2) {
- var r1 = self.localData[id1];
- var r2 = self.localData[id2];
- var data1 = r1._changes || r1.data;
- var data2 = r2._changes || r2.data;
+ var data = list.data;
+ data.sort(function (r1, r2) {
+ var data1 = r1.data;
+ var data2 = r2.data;
if (data1[order.name] < data2[order.name]) {
return order.asc ? -1 : 1;
}
@@ -3040,8 +3179,8 @@ var BasicModel = AbstractModel.extend({
}
}
if (element.type === 'list') {
- var listData = element._changes || element.data;
- _.each(listData, function (elemId) {
+ element = this._applyX2ManyOperations(element);
+ _.each(element.data, function (elemId) {
var elem = self.localData[elemId];
self._visitChildren(elem, fn);
});
diff --git a/addons/web/static/src/less/control_panel.less b/addons/web/static/src/less/control_panel.less
index 83048112815..4a022112e09 100644
--- a/addons/web/static/src/less/control_panel.less
+++ b/addons/web/static/src/less/control_panel.less
@@ -99,13 +99,16 @@
.o-flex-display();
.o-flex-flow(row, wrap);
- .o_cp_buttons, .o_cp_pager {
+ .o_cp_buttons {
.o-flex-display();
margin-right: auto;
+ > div {
+ margin-top: 5px;
+ }
}
-
- .o_cp_buttons > div {
- margin-top: 5px;
+ .o_cp_pager {
+ .o-flex-display();
+ margin-left: auto;
}
}
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 3bf541b097d..127bc9126bd 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -1522,6 +1522,178 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('edition of one2many field with pager', function (assert) {
+ assert.expect(31);
+
+ var ids = [];
+ for (var i = 0; i < 45; i++) {
+ var id = 10 + i;
+ ids.push(id);
+ this.data.partner.records.push({
+ id: id,
+ display_name: "relational record " + id,
+ });
+ }
+ this.data.partner.records[0].p = ids;
+
+ var saveCount = 0;
+ var checkRead = false;
+ var readIDs;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ archs: {
+ 'partner,false,form': '',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'read' && checkRead) {
+ readIDs = args.args[0];
+ checkRead = false;
+ }
+ if (args.method === 'write') {
+ saveCount++;
+ var nbCommands = args.args[1].p.length;
+ var nbLinkCommands = _.filter(args.args[1].p, function (command) {
+ return command[0] === 4;
+ }).length;
+ switch(saveCount) {
+ case 1:
+ assert.strictEqual(nbCommands, 46,
+ "should send 46 commands (one for each record)");
+ assert.strictEqual(nbLinkCommands, 45,
+ "should send a LINK_TO command for each existing record");
+ assert.deepEqual(args.args[1].p[45], [0, false, {
+ display_name: 'new record',
+ }], "should sent a CREATE command for the new record");
+ break;
+ case 2:
+ assert.strictEqual(nbCommands, 46,
+ "should send 46 commands");
+ assert.strictEqual(nbLinkCommands, 45,
+ "should send a LINK_TO command for each existing record");
+ assert.deepEqual(args.args[1].p[45], [2, 10, false],
+ "should sent a DELETE command for the deleted record");
+ break;
+ case 3:
+ assert.strictEqual(nbCommands, 47,
+ "should send 47 commands");
+ assert.strictEqual(nbLinkCommands, 43,
+ "should send a LINK_TO command for each existing record");
+ assert.deepEqual(args.args[1].p[43],
+ [0, false, {display_name: 'new record page 1'}],
+ "should sent correct CREATE command");
+ assert.deepEqual(args.args[1].p[44],
+ [0, false, {display_name: 'new record page 2'}],
+ "should sent correct CREATE command");
+ assert.deepEqual(args.args[1].p[45],
+ [2, 11, false],
+ "should sent correct DELETE command");
+ assert.deepEqual(args.args[1].p[46],
+ [2, 52, false],
+ "should sent correct DELETE command");
+ break;
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'there should be 40 records on page 1');
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '1-40 / 45', "pager range should be correct");
+
+ // add a record on page one
+ checkRead = true;
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o-kanban-button-new').click();
+ $('.modal input').val('new record').trigger('input');
+ $('.modal .modal-footer .btn-primary:first').click(); // save and close
+ // checks
+ assert.strictEqual(readIDs, undefined, "should not have read any record");
+ assert.strictEqual(form.$('span:contains(new record)').length, 0,
+ "new record should be on page 2");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'there should be 40 records on page 1');
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '1-40 / 46', "pager range should be correct");
+ assert.strictEqual(form.$('.o_kanban_record:first span:contains(new record)').length,
+ 0, 'new record should not be on page 1');
+ // save
+ form.$buttons.find('.o_form_button_save').click();
+
+ // delete a record on page one
+ checkRead = true;
+ form.$buttons.find('.o_form_button_edit').click();
+ assert.strictEqual(form.$('.o_kanban_record:first span:contains(relational record 10)').length,
+ 1, 'first record should be the one with id 10 (next checks rely on that)');
+ form.$('.delete_icon:first').click();
+ // checks
+ assert.deepEqual(readIDs, [50],
+ "should have read a record (to display 40 records on page 1)");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'there should be 40 records on page 1');
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '1-40 / 45', "pager range should be correct");
+ // save
+ form.$buttons.find('.o_form_button_save').click();
+
+ // add and delete records in both pages
+ form.$buttons.find('.o_form_button_edit').click();
+ checkRead = true;
+ readIDs = undefined;
+ // add and delete a record in page 1
+ form.$('.o-kanban-button-new').click();
+ $('.modal input').val('new record page 1').trigger('input');
+ $('.modal .modal-footer .btn-primary:first').click(); // save and close
+ assert.strictEqual(form.$('.o_kanban_record:first span:contains(relational record 11)').length,
+ 1, 'first record should be the one with id 11 (next checks rely on that)');
+ form.$('.delete_icon:first').click();
+ assert.deepEqual(readIDs, [51],
+ "should have read a record (to display 40 records on page 1)");
+ // add and delete a record in page 2
+ form.$('.o_x2m_control_panel .o_pager_next').click();
+ assert.strictEqual(form.$('.o_kanban_record:first span:contains(relational record 52)').length,
+ 1, 'first record should be the one with id 52 (next checks rely on that)');
+ checkRead = true;
+ readIDs = undefined;
+ form.$('.delete_icon:first').click();
+ form.$('.o-kanban-button-new').click();
+ $('.modal input').val('new record page 2').trigger('input');
+ $('.modal .modal-footer .btn-primary:first').click(); // save and close
+ assert.strictEqual(readIDs, undefined, "should not have read any record");
+ // checks
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5,
+ 'there should be 5 records on page 2');
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '41-45 / 45', "pager range should be correct");
+ assert.strictEqual(form.$('.o_kanban_record span:contains(new record page 1)').length,
+ 1, 'new records should be on page 2');
+ assert.strictEqual(form.$('.o_kanban_record span:contains(new record page 2)').length,
+ 1, 'new records should be on page 2');
+ // save
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
QUnit.test('sorting one2many fields', function (assert) {
assert.expect(4);
@@ -2357,6 +2529,45 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('editable o2m with onchange and required field: delete an invalid line', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.onchanges = {
+ turtles: function () {},
+ };
+ this.data.partner.records[0].turtles = [1];
+ this.data.turtle.records[0].product_id = 37;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('.o_data_cell:first').click();
+ form.$('.o_field_widget[name="product_id"] input').val('').trigger('keyup');
+ assert.verifySteps(['read', 'read'], 'no onchange should be done as line is invalid');
+ form.$('.o_list_record_delete').click();
+ assert.verifySteps(['read', 'read', 'onchange'], 'onchange should have been done');
+
+ form.destroy();
+ });
+
QUnit.test('onchange in a one2many', function (assert) {
assert.expect(1);
@@ -2515,6 +2726,75 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('one2many and onchange (with command DELETE_ALL)', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.p = [[5]];
+ },
+ p: function () {}, // dummy onchange on the o2m to execute _isX2ManyValid()
+ };
+ this.data.partner.records[0].p = [2];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'',
+ mockRPC: function (method, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].p, [
+ [0, false, {display_name: 'z'}],
+ [2, 2, false],
+ ], "correct commands should be sent");
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "o2m should contain one row");
+
+ // empty o2m by triggering the onchange
+ form.$('.o_field_widget[name=foo]').val('trigger onchange').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "rows of the o2m should have been deleted");
+
+ // add two new subrecords
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_widget[name=display_name]').val('x').trigger('input');
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_widget[name=display_name]').val('y').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ "o2m should contain two rows");
+
+ // empty o2m by triggering the onchange
+ form.$('.o_field_widget[name=foo]').val('trigger onchange again').trigger('input');
+
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "rows of the o2m should have been deleted");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_widget[name=display_name]').val('z').trigger('input');
+
+ form.$buttons.find('.o_form_button_save').click();
+ form.destroy();
+ });
+
QUnit.test('one2many list (editable): readonly domain is evaluated', function (assert) {
assert.expect(2);
@@ -2782,6 +3062,41 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('value of invisible x2many fields is correctly evaluated in context', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].timmy = [12];
+ this.data.partner.records[0].p = [2, 3];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'name_search') {
+ assert.deepEqual(
+ args.kwargs.context, {
+ p: [[4, 2, false], [4, 3, false]],
+ timmy: [[6, false, [12]]],
+ }, 'values of x2manys should have been correctly evaluated in context');
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_widget[name=product_id] input').click();
+
+ form.destroy();
+ });
+
QUnit.test('one2many list, editable, with many2one and with context with parent key', function (assert) {
assert.expect(1);
@@ -4987,6 +5302,96 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('onchange with 40+ commands for a many2many', function (assert) {
+ // this test ensures that the basic_model correctly handles more LINK_TO
+ // commands than the limit of the dataPoint (40 for x2many kanban)
+ assert.expect(23);
+
+ // create a lot of partner_types that will be linked by the onchange
+ var commands = [[5]];
+ for (var i = 0; i < 45; i++) {
+ var id = 100 + i;
+ this.data.partner_type.records.push({id: id, display_name: "type " + id});
+ commands.push([4, id]);
+ }
+ this.data.partner.onchanges = {
+ foo: function (obj) {
+ obj.timmy = commands;
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'write') {
+ assert.strictEqual(args.args[1].timmy[0][0], 6,
+ "should send a command 6");
+ assert.strictEqual(args.args[1].timmy[0][2].length, 45,
+ "should replace with 45 ids");
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.verifySteps(['read']);
+
+ form.$('.o_field_widget[name=foo]').val('trigger onchange').trigger('input');
+
+ assert.verifySteps(['read', 'onchange', 'read']);
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '1-40 / 45', "pager should be correct");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'there should be 40 records displayed on page 1');
+
+ form.$('.o_pager_next').click();
+ assert.verifySteps(['read', 'onchange', 'read', 'read']);
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '41-45 / 45', "pager should be correct");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5,
+ 'there should be 5 records displayed on page 2');
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '1-40 / 45', "pager should be correct");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'there should be 40 records displayed on page 1');
+
+ form.$('.o_pager_next').click();
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '41-45 / 45', "pager should be correct");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 5,
+ 'there should be 5 records displayed on page 2');
+
+ form.$('.o_pager_next').click(); // back to page 1
+ assert.strictEqual(form.$('.o_x2m_control_panel .o_pager_counter').text().trim(),
+ '1-40 / 45', "pager should be correct");
+ assert.strictEqual(form.$('.o_kanban_record:not(".o_kanban_ghost")').length, 40,
+ 'there should be 40 records displayed on page 1');
+
+ form.destroy();
+ });
+
QUnit.module('FieldStatus');
QUnit.test('static statusbar widget on many2one field', function (assert) {
diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js
index 9fbc2acdd88..4684b50a871 100644
--- a/addons/web/static/tests/views/basic_model_tests.js
+++ b/addons/web/static/tests/views/basic_model_tests.js
@@ -302,6 +302,89 @@ QUnit.module('Views', {
model.destroy();
});
+ QUnit.test('notifyChange on a one2many', function (assert) {
+ assert.expect(9);
+
+ this.data.partner.records[1].product_ids = [37];
+ this.params.fieldNames = ['product_ids'];
+
+ var model = createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ assert.strictEqual(args.model, 'product');
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var o2mParams = {
+ modelName: 'product',
+ fields: this.data.product.fields,
+ fieldNames: ['display_name'],
+ };
+ model.load(this.params).then(function (resultID) {
+ model.load(o2mParams).then(function (newRecordID) {
+ var record = model.get(resultID);
+ var x2mListID = record.data.product_ids.id;
+
+ assert.strictEqual(record.data.product_ids.count, 1,
+ "there should be one record in the relation");
+
+ // trigger a 'ADD' command
+ model.notifyChanges(resultID, {product_ids: {operation: 'ADD', id: newRecordID}});
+
+ assert.deepEqual(model.localData[x2mListID]._changes, [{
+ operation: 'ADD', id: newRecordID,
+ }], "_changes should be correct");
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.count, 2,
+ "there should be two records in the relation");
+
+ // trigger a 'UPDATE' command
+ model.notifyChanges(resultID, {product_ids: {operation: 'UPDATE', id: newRecordID}});
+
+ assert.deepEqual(model.localData[x2mListID]._changes, [{
+ operation: 'ADD', id: newRecordID,
+ }, {
+ operation: 'UPDATE', id: newRecordID,
+ }], "_changes should be correct");
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.count, 2,
+ "there should be two records in the relation");
+
+ // trigger a 'REMOVE' command on the existing record
+ var existingRecordID = record.data.product_ids.data[0].id;
+ model.notifyChanges(resultID, {product_ids: {operation: 'REMOVE', ids: [existingRecordID]}});
+
+ assert.deepEqual(model.localData[x2mListID]._changes, [{
+ operation: 'ADD', id: newRecordID,
+ }, {
+ operation: 'UPDATE', id: newRecordID,
+ }, {
+ operation: 'REMOVE', id: existingRecordID,
+ }],
+ "_changes should be correct");
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.count, 1,
+ "there should be one record in the relation");
+
+ // trigger a 'REMOVE' command on the new record
+ model.notifyChanges(resultID, {product_ids: {operation: 'REMOVE', ids: [newRecordID]}});
+
+ assert.deepEqual(model.localData[x2mListID]._changes, [{
+ operation: 'REMOVE', id: existingRecordID,
+ }], "_changes should be correct");
+ record = model.get(resultID);
+ assert.strictEqual(record.data.product_ids.count, 0,
+ "there should be no record in the relation");
+ });
+
+ });
+ model.destroy();
+ });
+
QUnit.test('notifyChange on a many2one, without display_name', function (assert) {
assert.expect(3);
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index 1dc2dc337ab..b15e341adb6 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -4327,7 +4327,7 @@ QUnit.module('Views', {
});
QUnit.test('default_order on x2many embedded view', function (assert) {
- assert.expect(8);
+ assert.expect(11);
this.data.partner.fields.display_name.sortable = true;
this.data.partner.records[0].p = [1, 4];
@@ -4387,6 +4387,18 @@ QUnit.module('Views', {
assert.ok(form.$('.o_field_one2many tbody tr:eq(2) td:contains(xop)').length,
"record xop should be third");
+ // client-side sort on edit
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_one2many tbody tr:eq(1) td:contains(yop)').click();
+ $('.modal input[name="foo"]').val('zzz').trigger("input");
+ $('.modal .modal-footer button:first').click(); // Save
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(0) td:contains(zzz)').length,
+ "record zzz should be first");
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(1) td:contains(zop)').length,
+ "record zop should be second");
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(2) td:contains(xop)').length,
+ "record xop should be third");
+
form.destroy();
});