diff --git a/addons/web/static/src/model/relational_model/record.js b/addons/web/static/src/model/relational_model/record.js index 795d3f2ebde..bc0a302dab3 100644 --- a/addons/web/static/src/model/relational_model/record.js +++ b/addons/web/static/src/model/relational_model/record.js @@ -1022,6 +1022,12 @@ export class Record extends DataPoint { changes: localChanges, fieldNames: onChangeFields, evalContext: this.evalContext, + onError: (e) => { + for (const fieldName in localChanges) { + this._setInvalidField(fieldName); + } + throw e; + }, }); Object.assign(changes, this._parseServerValues(otherChanges, this.data)); } diff --git a/addons/web/static/src/model/relational_model/relational_model.js b/addons/web/static/src/model/relational_model/relational_model.js index 1b1af02b6dc..20ea0dd2395 100644 --- a/addons/web/static/src/model/relational_model/relational_model.js +++ b/addons/web/static/src/model/relational_model/relational_model.js @@ -575,7 +575,10 @@ export class RelationalModel extends Model { * @param {Object} [param.evalContext=config.context] * @returns Promise */ - async _onchange(config, { changes = {}, fieldNames = [], evalContext = config.context }) { + async _onchange( + config, + { changes = {}, fieldNames = [], evalContext = config.context, onError } + ) { const { fields, activeFields, resModel, resId } = config; let context = config.context; if (fieldNames.length === 1) { @@ -584,7 +587,15 @@ export class RelationalModel extends Model { } const spec = getFieldsSpec(activeFields, fields, evalContext, { withInvisible: true }); const args = [resId ? [resId] : [], changes, fieldNames, spec]; - const response = await this.orm.call(resModel, "onchange", args, { context }); + let response; + try { + response = await this.orm.call(resModel, "onchange", args, { context }); + } catch (e) { + if (onError) { + return onError(e); + } + throw e; + } if (response.warning) { const { type, title, message, className, sticky } = response.warning; if (type === "dialog") { 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 da0d6e113ba..7c18bdd939e 100644 --- a/addons/web/static/tests/views/fields/one2many_field_tests.js +++ b/addons/web/static/tests/views/fields/one2many_field_tests.js @@ -8923,7 +8923,7 @@ QUnit.module("Fields", (hooks) => { ); QUnit.test("display correct value after validation error", async function (assert) { - assert.expect(4); + assert.expect(5); /* * By-pass QUnit's and test's error handling because the error service needs to be active @@ -9002,10 +9002,8 @@ QUnit.module("Fields", (hooks) => { await editInput(target, ".o_field_widget[name=turtle_foo] input", "pinky"); await click(target, ".o_form_view"); assert.strictEqual(target.querySelector(".o_data_row .o_data_cell").textContent, "foo"); - - // we make sure here that when we save, the values are the current - // values displayed in the field. - await clickSave(target); + assert.hasClass(target.querySelector(".o_field_widget[name=turtles]"), "o_field_invalid"); + assert.ok(target.querySelector(".o_form_button_save").disabled); }); QUnit.test("propagate context to sub views without default_* keys", async function (assert) { 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 4bdfd251c98..80931bf87b9 100644 --- a/addons/web/static/tests/views/form/form_view_tests.js +++ b/addons/web/static/tests/views/form/form_view_tests.js @@ -6284,6 +6284,58 @@ QUnit.module("Views", (hooks) => { assert.containsOnce(document.body, ".o_notification"); }); + QUnit.test("onchange returns an error", async function (assert) { + registry.category("services").add("error", errorService); + registry.category("error_dialogs").add("odoo.exceptions.UserError", WarningDialog); + // remove the override in qunit.js that swallows unhandledrejection errors + // s.t. we let the error service handle them + const originalOnUnhandledRejection = window.onunhandledrejection; + window.onunhandledrejection = () => {}; + registerCleanup(() => { + window.onunhandledrejection = originalOnUnhandledRejection; + }); + + serverData.models.partner.onchanges = { int_field: () => {} }; + await makeView({ + type: "form", + resModel: "partner", + serverData, + arch: `
`, + resId: 2, + mockRPC(route, args) { + if (args.method === "onchange" && args.args[1].int_field === 64) { + throw makeServerError({ message: "Some business message" }); + } + }, + }); + + assert.strictEqual( + target.querySelector(".o_field_widget[name=int_field] input").value, + "9" + ); + + await editInput(target, ".o_field_widget[name=int_field] input", 64); + + assert.containsOnce(document.body, ".modal"); + assert.strictEqual( + document.body.querySelector(".modal-body").textContent, + "Some business message" + ); + assert.hasClass( + target.querySelector('.o_field_widget[name="int_field"]'), + "o_field_invalid" + ); + + await click(target.querySelector(".modal .btn-close")); + + assert.containsNone(document.body, ".modal"); + + await editInput(target, ".o_field_widget[name=int_field] input", 32); + + assert.containsNone(document.body, ".modal"); + assert.containsNone(target, ".o_field_invalid"); + }); + QUnit.test("button box is rendered in create mode", async function (assert) { await makeView({ type: "form",