[FIX] web: BasicModel: correctly generate x2many commands

This commit fixes two problems. First, the commands weren't
correctly generated for relational fields inside relational fields.
For example, if a form view contained a one2many containing itself
a many2many, the commands weren't generated for the many2many
inside the one2many. Thus, it's value couldn't be saved.

Second, when doing an onchange RPC, all record data should be sent
to the server, not only what have changed. Before this rev., for
x2many fields, commands were sent only if the field had changed.
This commit is contained in:
Aaron Bohy
2017-04-11 19:44:38 +02:00
parent 2932aa64bc
commit 2bca653584
3 changed files with 131 additions and 51 deletions
@@ -589,28 +589,7 @@ var BasicModel = AbstractModel.extend({
}
var shouldReload = 'reload' in options ? options.reload : true;
var method = self.isNew(record_id) ? 'create' : 'write';
var changes = _.extend({}, record._changes);
var commands = self._generateX2ManyCommands(record);
for (var fieldName in commands) {
if (commands[fieldName] === null) {
delete changes[fieldName];
} else {
changes[fieldName] = commands[fieldName];
}
}
// replace local ids by actual ids in relational changes
for (fieldName in changes) {
var field = record.fields[fieldName];
if (field.type === 'many2one') {
if (changes[fieldName]) {
var res_id = self.localData[changes[fieldName]].res_id;
changes[fieldName] = res_id;
} else {
changes[fieldName] = false;
}
}
}
var changes = self._generateChanges(record);
if (method === 'create') {
var fieldNames = record.getFieldNames();
@@ -864,7 +843,7 @@ var BasicModel = AbstractModel.extend({
var context = this._getContext(record);
var rawData = this.get(record.id, {raw: true}).data;
var commands = this._generateX2ManyCommands(record);
var commands = this._generateX2ManyCommands(record, false);
// compute field values
var fieldNames = record.getFieldNames();
@@ -1622,41 +1601,65 @@ var BasicModel = AbstractModel.extend({
}
return $.when.apply($, defs);
},
/**
* Generates an object mapping field names to their changed value in a given
* record (i.e. maps to the new value for basic fields, to the res_id for
* many2ones and to commands for x2manys).
*
* @private
* @param {Object} record
* @returns {Object} a map from changed fields to their new value
*/
_generateChanges: function (record) {
var changes = _.extend({}, record._changes);
var commands = this._generateX2ManyCommands(record, true);
for (var fieldName in record.fields) {
var type = record.fields[fieldName].type;
if (type === 'one2many' || type === 'many2many') {
if (commands[fieldName].length) { // replace localId by commands
changes[fieldName] = commands[fieldName];
} else { // no command -> no change for that field
delete changes[fieldName];
}
} else if (type === 'many2one' && fieldName in changes) {
var value = changes[fieldName];
changes[fieldName] = value ? this.localData[value].res_id : false;
}
}
return changes;
},
/**
* Read all x2many fields and generate the commands for the server to create
* or write them...
*
* @param {Object} record
* @param {boolean} [changesOnly=false] if true, only generates commands for
* fields that have changed
* @returns {Object} a map from some field names to commands
*/
_generateX2ManyCommands: function (record) {
_generateX2ManyCommands: function (record, changesOnly) {
var self = this;
var commands = {};
var type, list, relData, relIds, removedIds, addedIds, keptIds, relRecord, i;
var data = _.extend({}, record.data, record._changes);
var type;
for (var fieldName in record.fields) {
type = record.fields[fieldName].type;
if (type === 'many2many' || type === 'one2many') {
// skip if this field is empty
commands[fieldName] = [];
if (!data[fieldName]) {
commands[fieldName] = null;
// skip if this field is empty
continue;
}
list = this.localData[data[fieldName]];
if (!list._changes) {
commands[fieldName] = null;
// skip if this field hasn't changed
var list = this.localData[data[fieldName]];
if (changesOnly && !list._changes) {
// if only changes are requested, skip if there is no change
continue;
}
relData = _.map(list._changes, function (localId) {
var relData = _.map(list._changes || list.data, function (localId) {
return self.localData[localId];
});
relIds = _.pluck(relData, 'res_id');
commands[fieldName] = [];
var relIds = _.pluck(relData, 'res_id');
if (type === 'many2many' || list._forceM2MLink) {
// deliberately generate a single 'replace' command instead
// of a 'delete' and a 'link' commands with the exact diff
@@ -1665,20 +1668,17 @@ var BasicModel = AbstractModel.extend({
// an complete override of the actual value of the m2m)
commands[fieldName].push(x2ManyCommands.replace_with(relIds));
} else if (type === 'one2many') {
removedIds = _.difference(list.res_ids, relIds);
addedIds = _.difference(relIds, list.res_ids);
keptIds = _.intersection(list.res_ids, relIds);
// the didChange variable keep track of the fact that at
var removedIds = _.difference(list.res_ids, relIds);
var addedIds = _.difference(relIds, list.res_ids);
var keptIds = _.intersection(list.res_ids, relIds);
// the didChange variable keeps track of the fact that at
// least one id was updated
var didChange = false;
var command, i, relRecord;
for (i = 0; i < keptIds.length; i++) {
relRecord = _.findWhere(relData, {res_id: keptIds[i]});
var command;
if (!_.isEmpty(relRecord._changes)) {
var r = this.get(relRecord.id, {raw: true});
var changes = _.pick(r.data, Object.keys(relRecord._changes));
var changes = this._generateChanges(relRecord);
command = x2ManyCommands.update(relRecord.res_id, changes);
didChange = true;
} else {
@@ -1686,11 +1686,11 @@ var BasicModel = AbstractModel.extend({
}
commands[fieldName].push(command);
}
if (!didChange && addedIds.length === 0 && removedIds.length === 0) {
if (changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) {
// in this situation, we have no changed ids, no added
// ids and no removed ids, so we can safely ignore the
// last changes
commands[fieldName] = null;
commands[fieldName] = [];
}
for (i = 0; i < addedIds.length; i++) {
relRecord = _.findWhere(relData, {res_id: addedIds[i]});
@@ -1703,7 +1703,6 @@ var BasicModel = AbstractModel.extend({
}
}
}
return commands;
},
/**
@@ -2324,7 +2324,7 @@ QUnit.module('relational_fields', {
});
QUnit.test('many2one and many2many in one2many', function (assert) {
assert.expect(5);
assert.expect(11);
this.data.turtle.records[1].product_id = 37;
this.data.partner.records[0].turtles = [2, 3];
@@ -2342,7 +2342,7 @@ QUnit.module('relational_fields', {
'<field name="product_id"/>' +
'</group>' +
'</form>' +
'<tree>' +
'<tree editable="top">' +
'<field name="display_name"/>' +
'<field name="product_id"/>' +
'<field name="partner_ids" widget="many2many_tags"/>' +
@@ -2351,6 +2351,20 @@ QUnit.module('relational_fields', {
'</group>' +
'</form>',
res_id: 1,
mockRPC: function (route, args) {
if (args.method === 'write') {
var commands = args.args[1].turtles;
assert.strictEqual(commands.length, 2,
"should have generated 2 commands");
assert.deepEqual(commands[0], [1, 2, {
partner_ids: [[6, false, [2, 1]]],
product_id: 41,
}], "generated commands should be correct");
assert.deepEqual(commands[1], [4, 3, false],
"generated commands should be correct");
}
return this._super.apply(this, arguments);
},
});
assert.strictEqual(form.$('.o_data_row').length, 2,
@@ -2366,6 +2380,32 @@ QUnit.module('relational_fields', {
assert.strictEqual($('.modal .o_form_field').text(), "xphone",
'should display the form view dialog with the many2one value');
$('.modal-footer button').click(); // close the modal
form.$buttons.find('.o_form_button_edit').click();
// edit the m2m of first row
form.$('.o_list_view tbody td:first()').click();
// remove a tag
form.$('.o_form_field_many2manytags .badge:contains(aaa) .o_delete').click();
assert.strictEqual(form.$('.o_selected_row .o_form_field_many2manytags .o_badge_text:contains(aaa)').length, 0,
"tag should have been correctly removed");
// add a tag
var $m2mInput = form.$('.o_selected_row .o_form_field_many2manytags input');
$m2mInput.click();
$m2mInput.autocomplete('widget').find('li:first()').click();
assert.strictEqual(form.$('.o_selected_row .o_form_field_many2manytags .o_badge_text:contains(first record)').length, 1,
"tag should have been correctly added");
// edit the m2o of first row
var $m2oInput = form.$('.o_selected_row .o_form_field_many2one:first input');
$m2oInput.click();
$m2oInput.autocomplete('widget').find('li:contains(xpad)').mouseover().click();
assert.strictEqual(form.$('.o_selected_row .o_form_field_many2one:first input').val(), 'xpad',
"m2o value should have been updated");
// save (should correctly generate the commands)
form.$buttons.find('.o_form_button_save').click();
form.destroy();
});
@@ -2188,6 +2188,47 @@ QUnit.module('Views', {
form.destroy();
});
QUnit.test('unchanged relational data is sent for onchanges', function (assert) {
assert.expect(1);
this.data.partner.records[1].p = [4];
this.data.partner.onchanges = {
foo: function (obj) {
obj.int_field = obj.foo.length + 1000;
},
};
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<group>' +
'<field name="foo"/>' +
'<field name="int_field"/>' +
'<field name="p">' +
'<tree>' +
'<field name="foo"/>' +
'<field name="bar"/>' +
'</tree>' +
'</field>' +
'</group>' +
'</form>',
res_id: 2,
mockRPC: function (route, args) {
if (args.method === 'onchange') {
assert.deepEqual(args.args[1].p, [[4, 4, false]],
"should send a command for field p even if it hasn't changed");
}
return this._super.apply(this, arguments);
},
});
form.$buttons.find('.o_form_button_edit').click();
form.$('.o_form_input:first').val('trigger an onchange').trigger('input');
form.destroy();
});
QUnit.test('navigation with tab key in form view', function (assert) {
assert.expect(2);