[FIX] web: nested one2manys, onchange and no command

Let's assume the following situation. We have a form view with a
one2many field A displayed as a list. In the list, there is a
one2many field B. B can't be edited, its value is computed by
an onchange. By default, it contains a single record (i.e. the
first value returned by the onchange is [[5], [0, 0, {...}]]).
When another field (say C) changes, B's value is re-computed to
[[5]]. Moreover, there is an onchange on A.

In this form view, let's assume the following scenario. Create a
new record and add a line to A. In this new line, B already
contains a record. Change C. This triggers an onchange that
returns [[5]], and B is now empty. It triggers a second onchange,
on the main record (as field A changed).

Before this commit, in this second onchange, B's value wasn't sent
among the other values of the new line.

The spec says that for onchanges, we must send all data, not only
what has really changed. From that perspective, the above scenario
highlights an issue.

That issue had two root causes. First, commit [1] wrongly fixed
another issue, and as a consequence, when building what to send
for the onchange, we didn't generate the values for fields that
hadn't changed inside an x2many (for added subrecords at least).
This commit reverts the fix of [1], and fixes it differently by
only sending a command 1 (update) after a command 4 (link to)
when the record is dirty (i.e. when it has been modified). See
[1] for context and details.

Second, the code that generates the values to send to onchanges is
the same as the one that generates the values to save records
(write or create). However, when saving, we only send what has
really changed. The values are at some point processed to remove
empty command lists from the list of changes (as it means that
nothing changed). However, here we ignored the flag that stated
whether we want all field values or just what has changed. This
commit takes the flag into account before removing the field's
value.

[1] https://github.com/odoo/odoo/commit/3e3a244e1afc4d74920a6302a14fa2590e8b6648

Issue reported in task~2352524
  Model: account.move
  One2Many (A): account.move.lines
  Nested computed One2Many (B): tax_detail_ids
  Field triggering the onchange (C): tax_ids

closes odoo/odoo#67739

X-original-commit: a3732031d38d7c7e93565cdd3cd4fdf838ae54ec
Signed-off-by: Géry Debongnie (ged) <ged@openerp.com>
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
Co-authored-by: Géry Debongnie <ged@odoo.com>
This commit is contained in:
Aaron Bohy
2021-03-12 09:42:21 +00:00
co-authored by Géry Debongnie
parent a004143a0c
commit 1988db86aa
3 changed files with 81 additions and 5 deletions
@@ -3253,7 +3253,7 @@ var BasicModel = AbstractModel.extend({
var type = record.fields[fieldName].type;
var value;
if (type === 'one2many' || type === 'many2many') {
if (commands[fieldName] && commands[fieldName].length) { // replace localId by commands
if (!options.changesOnly || (commands[fieldName] && commands[fieldName].length)) { // replace localId by commands
changes[fieldName] = commands[fieldName];
} else { // no command -> no change for that field
delete changes[fieldName];
@@ -3424,12 +3424,12 @@ var BasicModel = AbstractModel.extend({
commands[fieldName].push(x2ManyCommands.link_to(list.res_ids[i]));
continue;
}
changes = this._generateChanges(relRecord, _.extend({}, options, {changesOnly: true}));
changes = this._generateChanges(relRecord, options);
if (!this.isNew(relRecord.id)) {
// the subrecord already exists in db
commands[fieldName].push(x2ManyCommands.link_to(relRecord.res_id));
delete changes.id;
if (!_.isEmpty(changes)) {
if (this.isDirty(relRecord.id)) {
delete changes.id;
commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, changes));
}
} else {
@@ -9687,6 +9687,82 @@ QUnit.module('fields', {}, function () {
form.destroy();
testUtils.unpatch(FieldOne2Many);
});
QUnit.test('nested one2many, onchange, no command value', async function (assert) {
// This test ensures that we always send all values to onchange rpcs for nested
// one2manys, even if some field hasn't changed. In this particular test case,
// a first onchange returns a value for the inner one2many, and a second onchange
// removes it, thus restoring the field to its initial empty value. From this point,
// the nested one2many value must still be sent to onchange rpcs (on the main record),
// as it might be used to compute other fields (so the fact that the nested o2m is empty
// must be explicit).
assert.expect(3);
this.data.turtle.fields.o2m = {
string: "o2m", type: "one2many", relation: 'partner', relation_field: 'trululu',
};
this.data.turtle.fields.turtle_bar.default = true;
this.data.partner.onchanges.turtles = function (obj) {};
this.data.turtle.onchanges.turtle_bar = function (obj) {
if (obj.turtle_bar) {
obj.o2m = [[5], [0, false, { display_name: "default" }]];
} else {
obj.o2m = [[5]];
}
};
let step = 1;
const form = await createView({
View: FormView,
model: 'partner',
data: this.data,
arch: `<form>
<field name="turtles">
<tree editable="bottom">
<field name="o2m"/>
<field name="turtle_bar"/>
</tree>
</field>
</form>`,
async mockRPC(route, args) {
if (step === 3 && args.method === 'onchange' && args.model === 'partner') {
assert.deepEqual(args.args[1].turtles[0][2], {
turtle_bar: false,
o2m: [], // we must send a value for this field
});
}
const result = await this._super(...arguments);
if (args.model === 'turtle') {
// sanity checks; this is what the onchanges on turtle must return
if (step === 2) {
assert.deepEqual(result.value, {
o2m: [[5], [0, false, { display_name: "default" }]],
turtle_bar: true,
});
}
if (step === 3) {
assert.deepEqual(result.value, {
o2m: [[5]],
});
}
}
return result;
},
});
step = 2;
await testUtils.dom.click(form.$('.o_field_x2many_list .o_field_x2many_list_row_add a'));
// use of owlCompatibilityExtraNextTick because we have an x2many field with a boolean field
// (written in owl), so when we add a line, we sequentially render the list itself
// (including the boolean field), so we have to wait for the next animation frame, and
// then we render the control panel (also in owl), so we have to wait again for the
// next animation frame
await testUtils.owlCompatibilityExtraNextTick();
step = 3;
await testUtils.dom.click(form.$('.o_data_row .o_field_boolean input'));
form.destroy();
});
});
});
});
@@ -1145,7 +1145,7 @@ odoo.define('web.basic_model_tests', function (require) {
mockRPC: function (route, args) {
if (args.method === 'create') {
// has to be done before the call to _super
assert.notOk('product_ids' in args.args[0], "should not have any value");
assert.deepEqual(args.args[0].product_ids, [], "should not have any command");
assert.notOk('category' in args.args[0], "should not have other fields");
assert.strictEqual(args.kwargs.context.active_field, 2,