From 2d61351705c08750e7850e45eb03639611e90e7c Mon Sep 17 00:00:00 2001 From: FrancoisGe Date: Tue, 25 Jul 2023 14:21:38 +0000 Subject: [PATCH] [IMP] web: update accepts commands for x2m Before this commit, the record.update function did not allow x2m fields to be updated. Thanks to this commit, you can update an x2m by passing a list of commands that will be applied to the x2m's static list. This makes it possible to update several fields, including x2m fields, while triggering only one onchange to the server. closes odoo/odoo#129507 Related: odoo/enterprise#44532 Signed-off-by: Aaron Bohy (aab) --- .../static/src/js/sale_product_field.js | 8 +-- .../static/src/js/sale_product_field.js | 7 ++- .../question_page_one2many_field.js | 6 +- .../question_page_one2many_field_tests.js | 2 +- .../src/model/relational_model/record.js | 32 ++++++++-- .../src/model/relational_model/static_list.js | 48 ++++++++------- .../static/src/views/list/list_renderer.js | 1 + .../views/fields/one2many_field_tests.js | 6 +- .../tests/views/form/form_view_tests.js | 60 +++++++++++++++++++ 9 files changed, 130 insertions(+), 40 deletions(-) diff --git a/addons/event_booth_sale/static/src/js/sale_product_field.js b/addons/event_booth_sale/static/src/js/sale_product_field.js index 333c706b939..4b0648a8e0a 100644 --- a/addons/event_booth_sale/static/src/js/sale_product_field.js +++ b/addons/event_booth_sale/static/src/js/sale_product_field.js @@ -1,7 +1,8 @@ /** @odoo-module **/ -import { patch } from "@web/core/utils/patch"; import { SaleOrderLineProductField } from '@sale/js/sale_product_field'; +import { x2ManyCommands } from "@web/core/orm_service"; +import { patch } from "@web/core/utils/patch"; patch(SaleOrderLineProductField.prototype, 'event_booth_sale', { @@ -60,13 +61,10 @@ patch(SaleOrderLineProductField.prototype, 'event_booth_sale', { } else { const { event_id, event_booth_category_id, event_booth_pending_ids } = closeInfo.eventBoothConfiguration; - this.props.record.data.event_booth_pending_ids.replaceWith( - event_booth_pending_ids, - { silent: true } - ); this.props.record.update({ event_id, event_booth_category_id, + event_booth_pending_ids: [x2ManyCommands.replaceWith(event_booth_pending_ids)], }); } } diff --git a/addons/sale_product_configurator/static/src/js/sale_product_field.js b/addons/sale_product_configurator/static/src/js/sale_product_field.js index 7761df6c7fd..58b5feaab22 100644 --- a/addons/sale_product_configurator/static/src/js/sale_product_field.js +++ b/addons/sale_product_configurator/static/src/js/sale_product_field.js @@ -1,9 +1,10 @@ /** @odoo-module */ -import { patch } from "@web/core/utils/patch"; -import { useService } from "@web/core/utils/hooks"; import { SaleOrderLineProductField } from '@sale/js/sale_product_field'; import { serializeDateTime } from "@web/core/l10n/dates"; +import { x2ManyCommands } from "@web/core/orm_service"; +import { useService } from "@web/core/utils/hooks"; +import { patch } from "@web/core/utils/patch"; import { ProductConfiguratorDialog } from "./product_configurator_dialog/product_configurator_dialog"; async function applyProduct(record, product) { @@ -28,11 +29,11 @@ async function applyProduct(record, product) { ptal => ptal.create_variant === "no_variant" && ptal.attribute_values.length > 1 ).map(ptal => ptal.selected_attribute_value_id); - proms.push(record.data.product_no_variant_attribute_value_ids.replaceWith(noVariantPTAVIds, { silent: true })); await Promise.all(proms); await record.update({ product_id: [product.id, product.display_name], product_uom_qty: product.quantity, + product_no_variant_attribute_value_ids: [x2ManyCommands.replaceWith(noVariantPTAVIds)], }); }; diff --git a/addons/survey/static/src/question_page/question_page_one2many_field.js b/addons/survey/static/src/question_page/question_page_one2many_field.js index d6a5519b9a0..015e6a36862 100644 --- a/addons/survey/static/src/question_page/question_page_one2many_field.js +++ b/addons/survey/static/src/question_page/question_page_one2many_field.js @@ -95,17 +95,19 @@ class QuestionPageOneToManyField extends X2ManyField { updateRecord, }); this._openRecord = async (params) => { - if (!await self.props.record.save()) { + const { record, name } = this.props; + if (!await record.save()) { // do not open question form as it won't be savable either. return; } if (params.record) { + params.record = record.data[name].records.find(r => r.resId === params.record.resId); // Force synchronization of fields that depend on sequence // (allowed_triggering_question_ids, is_placed_before_trigger) // as records may have been re-ordered before opening this one. await params.record.load(); } - openRecord(params); + await openRecord(params); }; this.canOpenRecord = true; } diff --git a/addons/survey/static/tests/components/question_page_one2many_field_tests.js b/addons/survey/static/tests/components/question_page_one2many_field_tests.js index afc182029ce..5c2a4579b3b 100644 --- a/addons/survey/static/tests/components/question_page_one2many_field_tests.js +++ b/addons/survey/static/tests/components/question_page_one2many_field_tests.js @@ -167,7 +167,7 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => { await click(target.querySelector(".o_data_row:nth-child(2) .o_data_cell")); // Edit content to trigger the expected actual save at row opening assert.verifySteps(["save parent form"]); - assert.containsNone(target, ".o_selected_row"); + assert.containsOnce(target, ".o_selected_row"); assert.containsOnce(target, ".modal .o_form_view"); }); diff --git a/addons/web/static/src/model/relational_model/record.js b/addons/web/static/src/model/relational_model/record.js index fcfbcb5da0f..4bed78e5ce1 100644 --- a/addons/web/static/src/model/relational_model/record.js +++ b/addons/web/static/src/model/relational_model/record.js @@ -5,6 +5,7 @@ import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { Domain, evalDomain } from "@web/core/domain"; import { serializeDate, serializeDateTime } from "@web/core/l10n/dates"; import { _t } from "@web/core/l10n/translation"; +import { x2ManyCommands } from "@web/core/orm_service"; import { pick } from "@web/core/utils/objects"; import { escape } from "@web/core/utils/strings"; import { DataPoint } from "./datapoint"; @@ -409,14 +410,12 @@ export class Record extends DataPoint { limit: limit || Number.MAX_SAFE_INTEGER, context: {}, // will be set afterwards, see "_updateContext" in "_setEvalContext" }; - let staticList; const options = { onUpdate: ({ withoutOnchange } = {}) => - this._update({ [fieldName]: staticList }, { withoutOnchange }), + this._update({ [fieldName]: [] }, { withoutOnchange }), parent: this, }; - staticList = new this.model.constructor.StaticList(this.model, config, data, options); - return staticList; + return new this.model.constructor.StaticList(this.model, config, data, options); } _discard() { @@ -630,6 +629,7 @@ export class Record extends DataPoint { await Promise.all([ this._preprocessMany2oneChanges(changes), this._preprocessReferenceChanges(changes), + this._preprocessX2manyChanges(changes), ]); } @@ -741,6 +741,28 @@ export class Record extends DataPoint { return Promise.all(proms); } + async _preprocessX2manyChanges(changes) { + for (const [fieldName, value] of Object.entries(changes)) { + if ( + this.fields[fieldName].type !== "one2many" && + this.fields[fieldName].type !== "many2many" + ) { + continue; + } + const list = this.data[fieldName]; + for (const command of value) { + switch (command[0]) { + case x2ManyCommands.REPLACE_WITH: + await list._replaceWith(command[2]); + break; + default: + await list._applyCommands([command]); + } + } + changes[fieldName] = list; + } + } + _removeInvalidFields(fieldNames) { for (const fieldName of fieldNames) { this._invalidFields.delete(fieldName); @@ -931,7 +953,7 @@ export class Record extends DataPoint { const initialChanges = pick(this._changes, ...Object.keys(changes)); this._applyChanges(changes); try { - await this._onUpdate(changes, { withoutParentUpdate }); + await this._onUpdate({ withoutParentUpdate }); } catch (e) { this._applyChanges(initialChanges); throw e; diff --git a/addons/web/static/src/model/relational_model/static_list.js b/addons/web/static/src/model/relational_model/static_list.js index d26cd5374ae..30f984ad000 100644 --- a/addons/web/static/src/model/relational_model/static_list.js +++ b/addons/web/static/src/model/relational_model/static_list.js @@ -350,29 +350,10 @@ export class StaticList extends DataPoint { return this.model.mutex.exec(() => this._sortBy(fieldName)); } - async replaceWith(ids, { reload = false, silent = false } = {}) { + async replaceWith(ids, { reload = false } = {}) { return this.model.mutex.exec(async () => { - const resIds = reload ? ids : ids.filter((id) => !this._cache[id]); - if (resIds.length) { - const records = await this.model._loadRecords({ - ...this.config, - resIds, - context: this.context, - }); - for (const record of records) { - this._createRecordDatapoint(record); - } - } - this.records = ids.map((id) => this._cache[id]); - const updateCommandsToKeep = this._commands.filter( - (c) => c[0] === x2ManyCommands.UPDATE && ids.includes(c[1]) - ); - this._commands = [x2ManyCommands.replaceWith(ids)].concat(updateCommandsToKeep); - this._currentIds = [...ids]; - this.count = this._currentIds.length; - if (!silent) { - await this._onUpdate(); - } + await this._replaceWith(ids, { reload }); + await this._onUpdate(); }); } @@ -693,7 +674,7 @@ export class StaticList extends DataPoint { const { CREATE, UPDATE } = x2ManyCommands; const options = { parentRecord: this._parent, - onUpdate: async (changes, { withoutParentUpdate }) => { + onUpdate: async ({ withoutParentUpdate }) => { if (!this.currentIds.includes(record.isNew ? record._virtualId : record.resId)) { // the record hasn't been added to the list yet (we're currently creating it // from a dialog) @@ -822,6 +803,27 @@ export class StaticList extends DataPoint { await this.model._updateConfig(this.config, { limit, offset, orderBy }, { noReload: true }); } + async _replaceWith(ids, { reload = false } = {}) { + const resIds = reload ? ids : ids.filter((id) => !this._cache[id]); + if (resIds.length) { + const records = await this.model._loadRecords({ + ...this.config, + resIds, + context: this.context, + }); + for (const record of records) { + this._createRecordDatapoint(record); + } + } + this.records = ids.map((id) => this._cache[id]); + const updateCommandsToKeep = this._commands.filter( + (c) => c[0] === x2ManyCommands.UPDATE && ids.includes(c[1]) + ); + this._commands = [x2ManyCommands.replaceWith(ids)].concat(updateCommandsToKeep); + this._currentIds = [...ids]; + this.count = this._currentIds.length; + } + async _resequence(movedId, targetId) { const records = [...this.records]; const order = this.orderBy.find((o) => o.name === this.handleField); diff --git a/addons/web/static/src/views/list/list_renderer.js b/addons/web/static/src/views/list/list_renderer.js index c698430d469..42852e7c13d 100644 --- a/addons/web/static/src/views/list/list_renderer.js +++ b/addons/web/static/src/views/list/list_renderer.js @@ -876,6 +876,7 @@ export class ListRenderer extends Component { isCellReadonly(column, record) { return ( + this.isRecordReadonly(record) || (column.relatedPropertyField && record.selected && record.model.multiEdit) || evalDomain(column.modifiers.readonly, record.evalContext) ); diff --git a/addons/web/static/tests/views/fields/one2many_field_tests.js b/addons/web/static/tests/views/fields/one2many_field_tests.js index 2638a6a3f81..0f3d6aa52b9 100644 --- a/addons/web/static/tests/views/fields/one2many_field_tests.js +++ b/addons/web/static/tests/views/fields/one2many_field_tests.js @@ -3818,7 +3818,7 @@ QUnit.module("Fields", (hooks) => { }); QUnit.test("one2many list (non editable): edition", async function (assert) { - assert.expect(10); + assert.expect(11); let nbWrite = 0; serverData.models.partner.records[0].p = [2, 4]; @@ -3862,6 +3862,10 @@ QUnit.module("Fields", (hooks) => { // edit first record await click(target.querySelector(".o_list_renderer .o_data_cell")); + assert.hasClass( + target.querySelector(".o_list_renderer .o_data_cell"), + "o_readonly_modifier" + ); await editInput(target, ".modal .o_form_editable input", "new name"); diff --git a/addons/web/static/tests/views/form/form_view_tests.js b/addons/web/static/tests/views/form/form_view_tests.js index 00afa023f68..eb27925d1c6 100644 --- a/addons/web/static/tests/views/form/form_view_tests.js +++ b/addons/web/static/tests/views/form/form_view_tests.js @@ -13400,4 +13400,64 @@ QUnit.module("Views", (hooks) => { ]); } ); + + QUnit.test("widget update several fields including an x2m", async function (assert) { + assert.expect(7); + + serverData.models.partner.onchanges = { + name() {}, + p() {}, + }; + class TestWidget extends Component { + static template = xml`
`; + + onClick() { + this.props.record.update({ name: "New Name", p: [[0, false, { name: "yop" }]] }); + } + } + widgetRegistry.add("test", { + component: TestWidget, + fieldDependencies: [ + { name: "name", type: "char" }, + { name: "p", type: "one2many", relation: "partner" }, + ], + }); + + await makeView({ + type: "form", + resModel: "partner", + serverData, + arch: ` +
+ + + + + + + + `, + resId: 2, + async mockRPC(route, args) { + if (args.method === "onchange2") { + assert.strictEqual(args.args[1].name, "New Name"); + assert.strictEqual(args.args[1].p.length, 1); + assert.deepEqual(args.args[1].p[0][2], { name: "yop" }); + } + }, + }); + + assert.strictEqual(target.querySelector("[name=name] input").value, "name"); + assert.deepEqual( + [...target.querySelectorAll(".o_data_row")].map((el) => el.textContent), + [] + ); + + await click(target, ".o_widget_test button"); + assert.strictEqual(target.querySelector("[name=name] input").value, "New Name"); + assert.deepEqual( + [...target.querySelectorAll(".o_data_row")].map((el) => el.textContent), + ["yop"] + ); + }); });