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 b8211b2f048..1e14b4c1f41 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -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 { diff --git a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js index f3424f18502..7565f6ff41a 100644 --- a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js +++ b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js @@ -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: `
+ + + + + + +
`, + 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(); + }); }); }); }); diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index dd802790210..a97fb0ddd1c 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -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,