[FIX] web: sort and onchange in tabbed editable x2m

Two attempts of dealing with sorting (and resequencing) in tabbed (multi-pages)
x2many editable lists (see odoo/odoo@c2563db and odoo/odoo@983c7ae) have been
previously done, using a static attribute (`keepChangesUnsorted`) on the
datapoint.

This solution sadly only covers both cases separately, not if they are combined.
Morever, onchanges were not correctly managed in neither of these two cases.

A new approach is used here, by "freezing" and "unfreezing" the datapoint when
needed, which is more flexible.

Some modifications have been introduced:
 - when ordering a x2many, all records are now considered (and not only the
   records on the current page) ; an additional read is thus done on the sorted
   field for all records in the x2many relation
 - `setSort` is now asynchronous as a `read` might be needed
This commit is contained in:
Christophe Matthieu
2018-01-05 17:34:25 +01:00
committed by Martin Geubelle
parent d3cebdc4a2
commit b7ee1aa4b1
7 changed files with 484 additions and 178 deletions
@@ -1018,6 +1018,7 @@ var FieldX2Many = AbstractField.extend({
*/
_onEditLine: function (ev) {
ev.stopPropagation();
this.trigger_up('freeze_order', {id: this.value.id});
var editedRecord = this.value.data[ev.data.index];
this.renderer.setRowMode(editedRecord.id, 'edit')
.done(ev.data.onSuccess);
@@ -1104,6 +1105,7 @@ var FieldX2Many = AbstractField.extend({
*/
_onResequence: function (event) {
var self = this;
this.trigger_up('freeze_order', {id: this.value.id});
var rowIDs = event.data.rowIDs.slice();
var rowID = rowIDs.pop();
var defs = _.map(rowIDs, function (rowID, index) {
@@ -1123,6 +1125,11 @@ var FieldX2Many = AbstractField.extend({
operation: 'UPDATE',
id: rowID,
data: _.object([event.data.handleField], [event.data.offset + rowIDs.length]),
}).always(function () {
self.trigger_up('toggle_column_order', {
id: self.value.id,
name: event.data.handleField,
});
});
});
},
@@ -1229,6 +1236,7 @@ var FieldOne2Many = FieldX2Many.extend({
}
} else if (!this.creatingRecord) {
this.creatingRecord = true;
this.trigger_up('freeze_order', {id: this.value.id});
this._setValue({
operation: 'CREATE',
position: this.editable,
@@ -365,12 +365,6 @@ return core.Class.extend({
child.attrs.modifiers.column_invisible || false;
}
});
// detect editables list has they behave differently with respect
// to the sorting (changes are not sorted directly)
if (mode === 'list' && view.arch.attrs.editable) {
attrs.keepChangesUnsorted = true;
}
}
}
if (attrs.Widget.prototype.fieldsToFetch) {
@@ -37,6 +37,7 @@ odoo.define('web.BasicModel', function (require) {
* offset: {integer},
* openGroupByDefault: {boolean},
* orderedBy: {Object[]},
* orderedResIDs: {integer[]},
* parentID: {string},
* rawContext: {Object},
* relationField: {string},
@@ -410,29 +411,11 @@ var BasicModel = AbstractModel.extend({
return record;
}
// here, type === 'list'
// apply potential changes (only for x2many lists):
// for list having the flag _keepChangesUnsorted set to true (typically,
// editable lists), we sort before apply changes by default (to keep the
// changes unsorted, i.e. the edited line at the same place). However, if
// the user forces a sort, all changes done so far are sorted (this is the
// purpose of the _keepChangesUnsortedCount key), and changes done later
// on won't be sorted.
var count;
if (element._keepChangesUnsorted) {
// only apply a subset (by default 0) of changes before sorting
count = element._keepChangesUnsortedCount || 0;
element = this._applyX2ManyOperations(element, {to: count, position: 'bottom'});
} else {
// apply all changes before sorting
element = this._applyX2ManyOperations(element, {position: 'bottom'});
}
// apply potential changes (only for x2many lists)
element = this._applyX2ManyOperations(element);
this._sortList(element);
if (element._keepChangesUnsorted) {
// apply the remaining changes after the sort
element = this._applyX2ManyOperations(element, {from: count, position: 'bottom'});
}
if (element._changes) {
if (!element.orderedResIDs && element._changes) {
_.each(element._changes, function (change) {
if (change.operation === 'ADD' && change.isNew) {
element.data = _.without(element.data, change.id);
@@ -444,6 +427,7 @@ var BasicModel = AbstractModel.extend({
}
});
}
var list = {
aggregateValues: _.extend({}, element.aggregateValues),
context: _.extend({}, element.context),
@@ -921,6 +905,8 @@ var BasicModel = AbstractModel.extend({
// 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) {
@@ -952,6 +938,20 @@ var BasicModel = AbstractModel.extend({
record.fields = _.extend({}, record.fields, viewInfo.fields);
record.fieldsInfo = _.extend({}, record.fieldsInfo, viewInfo.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
@@ -968,7 +968,7 @@ var BasicModel = AbstractModel.extend({
*
* @param {string} list_id id for the list resource
* @param {string} fieldName valid field name
* @returns {this} so we can chain call this method
* @returns {Deferred}
*/
setSort: function (list_id, fieldName) {
var list = this.localData[list_id];
@@ -979,21 +979,26 @@ var BasicModel = AbstractModel.extend({
delete change.isNew;
});
}
// the user manually selected a sort order, so we sort the list with all
// the changes he made so far, even if it is editable, however, changes
// he'll made after won't be sorted if the list is editable
list._keepChangesUnsortedCount = list._changes ? list._changes.length : 0;
if (list.orderedBy.length === 0) {
list.orderedBy.push({name: fieldName, asc: true});
} else if (list.orderedBy[0].name === fieldName){
list.orderedBy[0].asc = !list.orderedBy[0].asc;
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);
}
return this;
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)
@@ -1048,6 +1053,34 @@ var BasicModel = AbstractModel.extend({
});
}
},
/**
* 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
@@ -1060,7 +1093,7 @@ var BasicModel = AbstractModel.extend({
* not by list or kanban controllers.
*
* @private
* @param {string} listID a valid handle for a list object
* @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
@@ -1080,6 +1113,12 @@ var BasicModel = AbstractModel.extend({
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;
});
},
@@ -1341,7 +1380,6 @@ var BasicModel = AbstractModel.extend({
// TODO: before registering the changes, verify that the x2many
// value has changed
record._changes[name] = list.id;
var shouldLoad = false;
list._changes = list._changes || [];
// save it in case of a [5] which will remove the _changes
@@ -1361,11 +1399,7 @@ var BasicModel = AbstractModel.extend({
}
if (command[0] === 1 && command[1]) {
// updating an existing record
recID = _.find(list.data, function (childID) {
var child = self.localData[childID];
return child.res_id === command[1];
});
rec = self.localData[recID];
rec = self.localData[list._cache[command[1]]];
}
if (!rec) {
var params = {
@@ -1391,18 +1425,25 @@ var BasicModel = AbstractModel.extend({
defs.push(self._applyOnChange(command[2], rec));
} else if (command[0] === 4) {
// LINK TO
// 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;
rec = self.localData[list._cache[command[1]]];
if (rec) {
// modifications done on a record are discarded if
// the onchange uses a LINK TO
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: command[1],
});
} else if (command[0] === 5) {
// DELETE ALL
list._changes = [{operation: 'REMOVE_ALL'}];
}
});
if (shouldLoad) {
defs.push(self._readUngroupedList(list));
}
defs.push(self._readUngroupedList(list));
} else {
var newValue = self._parseServerValue(field, val);
if (newValue !== oldValue) {
@@ -1540,12 +1581,6 @@ var BasicModel = AbstractModel.extend({
if (idInCommands && change.operation === 'ADD') {
idsToRemove = _.without(idsToRemove, change.id);
}
// decrement _keepChangesUnsortedCount if we filter out an
// operation whose index is smaller than the count (as there
// will be one operation less to apply before sorting)
if (idInCommands && index < list._keepChangesUnsortedCount) {
list._keepChangesUnsortedCount--;
}
return idInCommands;
});
_.each(idsToRemove, function (id) {
@@ -1653,18 +1688,7 @@ var BasicModel = AbstractModel.extend({
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]);
}
});
this._setDataInRange(list);
return list;
},
/**
@@ -2344,7 +2368,6 @@ var BasicModel = AbstractModel.extend({
rawContext: rawContext,
relationField: field.relation_field,
viewType: view ? view.type : fieldInfo.viewType,
_keepChangesUnsorted: fieldInfo.keepChangesUnsorted,
});
record.data[fieldName] = list.id;
if (!fieldInfo.__no_fetch) {
@@ -2624,6 +2647,7 @@ var BasicModel = AbstractModel.extend({
}
});
list = this._applyX2ManyOperations(list);
this._sortList(list);
if (type === 'many2many' || list._forceM2MLink) {
var relRecordCreated = _.filter(relRecordAdded, function (rec) {
return typeof rec.res_id === 'string';
@@ -3065,7 +3089,7 @@ var BasicModel = AbstractModel.extend({
offset: params.offset || (type === 'record' ? _.indexOf(res_ids, res_id) : 0),
openGroupByDefault: params.openGroupByDefault,
orderedBy: params.orderedBy || [],
_keepChangesUnsorted: params._keepChangesUnsorted,
orderedResIDs: params.orderedResIDs,
parentID: params.parentID,
rawContext: params.rawContext,
ref: params.ref || res_id,
@@ -3444,6 +3468,81 @@ var BasicModel = AbstractModel.extend({
return record;
});
},
/**
* 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
@@ -3503,6 +3602,7 @@ var BasicModel = AbstractModel.extend({
aggregateValues: aggregateValues,
groupedBy: list.groupedBy.slice(1),
orderedBy: list.orderedBy,
orderedResIDs: list.orderedResIDs,
limit: list.limit,
openGroupByDefault: list.openGroupByDefault,
parentID: list.id,
@@ -3555,70 +3655,44 @@ var BasicModel = AbstractModel.extend({
*/
_readUngroupedList: function (list) {
var self = this;
var def;
var ids = [];
var missingIds = [];
// 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 = currentResIDs[i];
ids.push(id);
if (!list._cache[id]) {
missingIds.push(id);
}
}
if (missingIds.length) {
if (fieldNames.length) {
def = this._rpc({
model: list.model,
method: 'read',
args: [missingIds, fieldNames],
context: list.getContext(),
});
} else {
def = $.when(_.map(missingIds, function (id) {
return {id:id};
}));
}
} else {
def = $.when();
}
return def.then(function (records) {
list.data = [];
_.each(ids, function (id) {
var dataPoint;
if (id in list._cache) {
dataPoint = self.localData[list._cache[id]];
} else {
dataPoint = self._makeDataPoint({
context: list.context,
data: _.findWhere(records, {id: id}),
fieldsInfo: list.fieldsInfo,
fields: list.fields,
modelName: list.model,
parentID: list.id,
viewType: list.viewType,
});
var def = $.when();
// add many2one records
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;
}
// 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) {
if (!list.orderedResIDs && list.orderedBy.length) {
var fieldNames = _.pluck(list.orderedBy, 'name');
def = this._readMissingFields(list, _.filter(list.res_ids, _.isNumber), fieldNames);
def.then(function () {
self._sortList(list);
});
if (_.contains(list.res_ids, id)) {
list.data.push(dataPoint.id);
}
}
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 upperBound = list.limit ? Math.min(list.offset + list.limit, currentCount) : currentCount;
var fieldNames = list.getFieldNames();
for (var i = list.offset; i < upperBound; i++) {
var resId = currentResIDs[i];
if (_.isNumber(resId)) {
resIDs.push(resId);
}
}
return self._readMissingFields(list, resIDs, fieldNames).then(function () {
if (list.res_ids.length <= list.limit) {
self._sortList(list);
} else {
// sortList has already been applied after first the read
self._setDataInRange(list);
}
return list;
});
return list;
});
},
/**
@@ -3689,6 +3763,38 @@ var BasicModel = AbstractModel.extend({
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
@@ -3720,14 +3826,28 @@ var BasicModel = AbstractModel.extend({
// only sort x2many lists
return;
}
var self = this;
if (list.orderedBy.length) {
var self = this;
if (list.orderedResIDs) {
var orderedResIDs = {};
for (var k = 0; k < list.orderedResIDs.length; k++) {
orderedResIDs[list.orderedResIDs[k]] = k;
}
list.res_ids.sort(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 data = list.data;
var res_ids = list.res_ids;
var compareRecords = function (record1ID, record2ID, level) {
var compareRecords = function (resId1, resId2, level) {
if(!level) {
level = 0;
}
@@ -3735,6 +3855,17 @@ var BasicModel = AbstractModel.extend({
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);
@@ -3747,17 +3878,9 @@ var BasicModel = AbstractModel.extend({
}
return compareRecords(record1ID, record2ID, level + 1);
};
data.sort(compareRecords);
// sort res_ids accordingly (only the current range of ids, the one
// mapping the data, needs to be sorted)
var preRangeIDs = res_ids.slice(0, list.offset); // resIDs before the range
var postRangeIDs = res_ids.slice(list.offset + list.limit); // resIDs after the range
var rangeIDs = _.map(data, function (dataPointID) {
return self.localData[dataPointID].res_id;
});
list.res_ids = preRangeIDs.concat(rangeIDs).concat(postRangeIDs);
list.res_ids.sort(compareRecords);
}
this._setDataInRange(list);
},
/**
* Updates the res_ids of the parent of a given element of type list.
@@ -12,9 +12,10 @@ var qweb = core.qweb;
var FormController = BasicController.extend({
custom_events: _.extend({}, BasicController.prototype.custom_events, {
open_one2many_record: '_onOpenOne2ManyRecord',
bounce_edit: '_onBounceEdit',
button_clicked: '_onButtonClicked',
freeze_order: '_onFreezeOrder',
open_one2many_record: '_onOpenOne2ManyRecord',
open_record: '_onOpenRecord',
toggle_column_order: '_onToggleColumnOrder',
}),
@@ -297,6 +298,17 @@ var FormController = BasicController.extend({
state.id = env.currentId;
this._super(state);
},
/**
* Calls unfreezeOrder when changing the mode.
*
* @override
*/
_setMode: function (mode, recordID) {
if ((recordID || this.handle) === this.handle) {
this.model.unfreezeOrder(this.handle);
}
return this._super.apply(this, arguments);
},
/**
* Updates the controller's title according to the new state
*
@@ -443,6 +455,17 @@ var FormController = BasicController.extend({
_onEdit: function () {
this._setMode('edit');
},
/**
* This method is called when someone tries to freeze the order, most likely
* in a x2many list view
*
* @private
* @param {OdooEvent} event
*/
_onFreezeOrder: function (event) {
event.stopPropagation();
this.model.freezeOrder(event.data.id);
},
/**
* Opens a one2many record (potentially new) in a dialog. This handler is
* o2m specific as in this case, the changes done on the related record
@@ -517,10 +540,12 @@ var FormController = BasicController.extend({
*/
_onToggleColumnOrder: function (event) {
event.stopPropagation();
this.model.setSort(event.data.id, event.data.name);
var field = event.data.field;
var state = this.model.get(this.handle);
this.renderer.confirmChange(state, state.id, [field]);
var self = this;
this.model.setSort(event.data.id, event.data.name).then(function () {
var field = event.data.field;
var state = self.model.get(self.handle);
self.renderer.confirmChange(state, state.id, [field]);
});
},
});
@@ -485,8 +485,10 @@ var ListController = BasicController.extend({
if (!data.groupedBy) {
this.pager.updateState({current_min: 1});
}
this.model.setSort(data.id, event.data.name);
this.update({});
var self = this;
this.model.setSort(data.id, event.data.name).then(function () {
self.update({});
});
},
/**
* In a grouped list view, each group can be clicked on to open/close them.
@@ -2271,7 +2271,7 @@ QUnit.module('relational_fields', {
});
QUnit.test('embedded one2many with handle widget', function (assert) {
assert.expect(7);
assert.expect(8);
this.data.partner.records[0].turtles = [1, 2, 3];
@@ -2321,6 +2321,15 @@ QUnit.module('relational_fields', {
"should have the 3 rows in the new order");
form.$buttons.find('.o_form_button_save').click();
assert.deepEqual(_.map(this.data.turtle.records, function(turtle) {
return _.pick(turtle, 'id', 'turtle_foo', 'turtle_int');
}), [
{id: 1, turtle_foo: "yop", turtle_int: 1},
{id: 2, turtle_foo: "blip", turtle_int:0},
{id: 3, turtle_foo: "kawa", turtle_int:21}
], "should have save the changed sequence");
assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "blipyopkawa",
"should still have the 3 rows in the new order");
@@ -2415,7 +2424,7 @@ QUnit.module('relational_fields', {
res_id: 1,
mockRPC: function (route, args) {
if (args.method === 'write') {
assert.deepEqual(args.args[1].turtles, [[1,1,{"turtle_int":2}],[4,2,false],[1,3,{"turtle_int":3}]],
assert.deepEqual(args.args[1].turtles, [[4,2,false],[1,1,{"turtle_int":2}],[1,3,{"turtle_int":3}]],
"should change all lines that have changed (the first one doesn't change because it has the same sequence)");
}
return this._super.apply(this, arguments);
@@ -2444,9 +2453,20 @@ QUnit.module('relational_fields', {
});
QUnit.test('onchange (with command 5) for embedded one2many with handle widget', function (assert) {
assert.expect(2);
assert.expect(3);
this.data.partner.records[0].turtles = [1, 2, 3];
var ids = [];
for (var i=10; i<50; i++) {
var id = 10 + i;
ids.push(id);
this.data.turtle.records.push({
id: id,
turtle_int: 0,
turtle_foo: "#" + id,
});
}
ids.push(1, 2, 3);
this.data.partner.records[0].turtles = ids;
this.data.partner.onchanges = {
turtles: function (obj) {
obj.turtles = [[5]].concat(obj.turtles);
@@ -2472,6 +2492,7 @@ QUnit.module('relational_fields', {
res_id: 1,
});
form.$('.o_pager_next').click();
assert.strictEqual(form.$('td.o_data_cell:not(.o_handle_cell)').text(), "yopblipkawa",
"should have the 3 rows in the correct order");
@@ -2486,7 +2507,10 @@ QUnit.module('relational_fields', {
{position: 'top'}
);
assert.strictEqual(form.$('.o_data_cell').text(), "blurpkawablip", "should display to record in 'turtle_int' order");
form.$buttons.find('.o_form_button_save').click();
form.$('.o_pager_next').click();
assert.strictEqual(form.$('.o_data_cell:not(.o_handle_cell)').text(), "blurpkawablip",
"should display to record in 'turtle_int' order");
@@ -2494,6 +2518,126 @@ QUnit.module('relational_fields', {
form.destroy();
});
QUnit.test('onchange followed by edition on the second page', function (assert) {
assert.expect(20);
var ids = [];
for (var i=1; i<85; i++) {
var id = 10 + i;
ids.push(id);
this.data.turtle.records.push({
id: id,
turtle_int: id/3|0,
turtle_foo: "#" + i,
});
}
ids.splice(41, 0, 1, 2, 3);
this.data.partner.records[0].turtles = ids;
this.data.partner.onchanges = {
turtles: function (obj) {
obj.turtles = [[5]].concat(obj.turtles);
},
};
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="turtles">' +
'<tree editable="top" default_order="turtle_int">' +
'<field name="turtle_int" widget="handle"/>' +
'<field name="turtle_foo"/>' +
'</tree>' +
'</field>' +
'</group>' +
'</sheet>' +
'</form>',
res_id: 1,
});
form.$buttons.find('.o_form_button_edit').click();
form.$('.o_pager_next').click();
form.$('.o_field_one2many .o_list_view tbody tr:eq(1) td:first').click();
form.$('.o_field_one2many .o_list_view tbody tr:eq(1) input:first').val('value 1').trigger('input');
form.$('.o_field_one2many .o_list_view tbody tr:eq(2) td:first').click();
form.$('.o_field_one2many .o_list_view tbody tr:eq(2) input:first').val('value 2').trigger('input');
assert.strictEqual(form.$('.o_data_row').length, 40, "should display 40 records");
assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 0, "should display '#39' at the first line");
form.$('.o_field_x2many_list_row_add a').click();
assert.strictEqual(form.$('.o_data_row').length, 40, "should display 39 records and the create line");
assert.strictEqual(form.$('.o_data_row:first .o_field_char').length, 1, "should display the create line in first position");
assert.strictEqual(form.$('.o_data_row:first .o_field_char').val(), "", "should an empty input");
assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 1, "should display '#39' at the second line");
form.$('.o_data_row input:first').val('value 3').trigger('input');
assert.strictEqual(form.$('.o_data_row:first .o_field_char').length, 1, "should display the create line in first position after onchange");
assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 1, "should display '#39' at the second line after onchange");
form.$('.o_field_x2many_list_row_add a').click();
assert.strictEqual(form.$('.o_data_row').length, 40, "should display 39 records and the create line");
assert.strictEqual(form.$('.o_data_row:first .o_field_char').length, 1, "should display the create line in first position");
assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(value 3))').index(), 1, "should display the created line at the second position");
assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 2, "should display '#39' at the third line");
form.destroy();
// bottom order
form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="turtles">' +
'<tree editable="bottom" default_order="turtle_int">' +
'<field name="turtle_int" widget="handle"/>' +
'<field name="turtle_foo"/>' +
'</tree>' +
'</field>' +
'</group>' +
'</sheet>' +
'</form>',
res_id: 1,
});
form.$buttons.find('.o_form_button_edit').click();
form.$('.o_pager_next').click();
form.$('.o_field_one2many .o_list_view tbody tr:eq(1) td:first').click();
form.$('.o_field_one2many .o_list_view tbody tr:eq(1) input:first').val('value 1').trigger('input');
form.$('.o_field_one2many .o_list_view tbody tr:eq(2) td:first').click();
form.$('.o_field_one2many .o_list_view tbody tr:eq(2) input:first').val('value 2').trigger('input');
assert.strictEqual(form.$('.o_data_row').length, 40, "should display 40 records");
assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#77))').index(), 39, "should display '#77' at the last line");
form.$('.o_field_x2many_list_row_add a').click();
assert.strictEqual(form.$('.o_data_row').length, 40, "should display 39 records and the create line");
assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#76))').index(), 38, "should display '#76' at the penultimate line");
assert.strictEqual(form.$('.o_data_row:has(.o_field_char)').index(), 39, "should display the create line at the last position");
form.$('.o_data_row input:first').val('value 3').trigger('input');
form.$('.o_field_x2many_list_row_add a').click();
assert.strictEqual(form.$('.o_data_row').length, 40, "should display 39 records and the create line");
assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#76))').index(), 38, "should display '#76' at the penultimate line");
assert.strictEqual(form.$('.o_data_row:has(.o_field_char)').index(), 39, "should display the create line at the last position");
form.destroy();
});
QUnit.test('embedded one2many with handle widget with minimum setValue calls', function (assert) {
var done = assert.async();
assert.expect(20);
@@ -5656,9 +5800,9 @@ QUnit.module('relational_fields', {
// enable the many2many onchange and generate it
form.$('input.o_field_integer[name="int_field"]').val('10').trigger('input');
assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xphonexenomorphe",
assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xenomorphexphone",
'should display the product xphone and xenomorphe');
assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "new linexphone first record firstxenomorphe second record ",
assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ",
'should display the name, one2many and many2many value');
// disable the many2many onchange
@@ -5688,19 +5832,19 @@ QUnit.module('relational_fields', {
// put list in readonly mode
form.$('input.o_field_integer[name="int_field"]').click();
assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xphonexenomorphe",
assert.strictEqual(form.$('.o_data_cell.o_required_modifier').text(), "xenomorphexphone",
'should display the product xphone and xenomorphe');
assert.strictEqual(form.$('.o_field_many2manytags:not(.o_input) .o_badge_text').text(), "first recordsecond record",
assert.strictEqual(form.$('.o_field_many2manytags:not(.o_input) .o_badge_text').text(), "second recordfirst record",
'should display the tag in readonly (first record and second record)');
form.$('input.o_field_integer[name="int_field"]').val('10').trigger('input');
assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "new linexphone first record firstxenomorphe second record ",
assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ",
'should display the name, one2many and many2many value');
form.$buttons.find('.o_form_button_save').click();
assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "new linexphone first record firstxenomorphe second record ",
assert.strictEqual(form.$('.o_data_row').text().replace(/\s+/g, ' '), "firstxenomorphe second record new linexphone first record ",
'should display the name, one2many and many2many value after save');
form.destroy();
@@ -6838,8 +6982,8 @@ QUnit.module('relational_fields', {
mockRPC: function (route, args) {
assert.step(args.method);
if (args.method === 'write') {
assert.strictEqual(args.args[1].turtles[42][0], 0);
assert.deepEqual(args.args[1].turtles[42][2], {turtle_foo: 'rainbow dash'});
assert.strictEqual(args.args[1].turtles[40][0], 0, 'should send a create command');
assert.deepEqual(args.args[1].turtles[40][2], {turtle_foo: 'rainbow dash'});
}
return this._super.apply(this, arguments);
},
@@ -6892,8 +7036,8 @@ QUnit.module('relational_fields', {
mockRPC: function (route, args) {
assert.step(args.method);
if (args.method === 'write') {
assert.strictEqual(args.args[1].turtles[0][0], 0);
assert.deepEqual(args.args[1].turtles[0][2], {turtle_foo: 'rainbow dash'});
assert.strictEqual(args.args[1].turtles[40][0], 0);
assert.deepEqual(args.args[1].turtles[40][2], {turtle_foo: 'rainbow dash'});
}
return this._super.apply(this, arguments);
},
@@ -6909,7 +7053,8 @@ QUnit.module('relational_fields', {
form.$('.o_field_x2many_list_row_add a').click();
assert.strictEqual(form.$('tr.o_data_row').length, 3,
"should have 3 data rows on the current page");
"should have 3 data rows on the current page (2 records and the created line)");
assert.ok(form.$('tr.o_data_row').first().hasClass('o_selected_row'),
"first row should be selected");
+20 -11
View File
@@ -989,7 +989,7 @@ QUnit.module('Views', {
});
QUnit.test('use default_order on editable tree: sort on demand', function (assert) {
assert.expect(8);
assert.expect(11);
this.data.foo.records[0].o2m = [1, 3];
this.data.bar.fields = {name: {string: "Name", type: "char", sortable: true}};
@@ -1029,12 +1029,21 @@ QUnit.module('Views', {
"Value 2 should be third (shouldn't be sorted)");
form.$('.o_form_sheet_bg').click(); // validate the row before sorting
$o2m.find('.o_column_sortable').click();
assert.ok(form.$('tbody tr:first td:contains(Value 3)').length,
"Value 3 should be first");
assert.ok(form.$('tbody tr:eq(1) td:contains(Value 2)').length,
$o2m.find('.o_column_sortable').click(); // resort list after edition
assert.strictEqual(form.$('tbody tr:first').text(), 'Value 1',
"Value 1 should be first");
assert.strictEqual(form.$('tbody tr:eq(1)').text(), 'Value 2',
"Value 2 should be second (should be sorted after saving)");
assert.ok(form.$('tbody tr:eq(2) td:contains(Value 1)').length,
assert.strictEqual(form.$('tbody tr:eq(2)').text(), 'Value 3',
"Value 3 should be third");
$o2m.find('.o_column_sortable').click();
assert.strictEqual(form.$('tbody tr:first').text(), 'Value 3',
"Value 3 should be first");
assert.strictEqual(form.$('tbody tr:eq(1)').text(), 'Value 2',
"Value 2 should be second (should be sorted after saving)");
assert.strictEqual(form.$('tbody tr:eq(2)').text(), 'Value 1',
"Value 1 should be third");
form.destroy();
@@ -1051,7 +1060,7 @@ QUnit.module('Views', {
ids.push(id);
this.data.bar.records.push({
id: id,
name: "Value " + id,
name: "Value " + (id < 10 ? '0' : '') + id,
});
}
this.data.foo.records[0].o2m = ids;
@@ -1074,15 +1083,15 @@ QUnit.module('Views', {
// Change page
form.$('.o_pager_next').click();
assert.ok(form.$('tbody tr:first td:contains(Value 44)').length,
assert.strictEqual(form.$('tbody tr:first').text(), 'Value 44',
"record 44 should be first");
assert.ok(form.$('tbody tr:eq(4) td:contains(Value 48)').length,
assert.strictEqual(form.$('tbody tr:eq(4)').text(), 'Value 48',
"record 48 should be last");
form.$('.o_column_sortable').click();
assert.ok(form.$('tbody tr:first td:contains(Value 48)').length,
assert.strictEqual(form.$('tbody tr:first').text(), 'Value 08',
"record 48 should be first");
assert.ok(form.$('tbody tr:eq(4) td:contains(Value 44)').length,
assert.strictEqual(form.$('tbody tr:eq(4)').text(), 'Value 04',
"record 44 should be first");
form.destroy();