[FIX] web: Record.update with onChange
The goal of this commit is to resolve:
=====================================
1. When you edit a Char or Boolean field with an onChange that returns
the value before editing, you must display the value of the onChange.
2. In the model, html fields are always Markup fields. But when we evaluate
a modifier containing an html field, we use the server value (a string or false).
3. When we edit a field and its onChange returns an server error, we want to
revert its value to the valuea before edition.
Solution:
=========
1. Always render when applying a change, even if the value does not change,
because onChange returns the initial value. To do this, we'll apply
the changes to record.data, then apply the changes from onChange.
The value will therefore change, which will cause all the components
observing the value in record.data to render.
2. When we update an html field, its value will always be markup and
we'll store it as a string in _textValue so that we can evaluate the
modifiers without the markup. For changes coming from the onChange, we
will store the value in _textValue and apply markup on it for record.data.
3. When an onChange returns an server error, we will apply the changes and then
revert them directly. This will have the effect of triggering a rendering
on all the components observing to the record.data corresponding to
the changes and so displaying the values before editing.
How to reproduce:
=================
Case 1:
------
- Go to a form view containing a boolean field with an onChange
- Check the boolean field
- The onChange returns with the value false
Before this commit:
The boolean field is always checked
After this commit:
The boolean field is no longer checked
Case 2:
------
- Go to a form view containing an html field and an field "x" with
invisible ="not html_field" and an onChange
- Edit the field "x"
- The onChange returns the value false for "html_field".
Before this commit:
The field "x" is always displayed
After this commit
The field "x" is no longer displayed
Case 3:
------
- Go to a form view containing a char field with an onChange
- Edit the field
- onChange returns a server error
Before this commit:
The field is marked as invalid and contains the value after editing.
After this commit:
The field contains the pre-edit value
closes odoo/odoo#136924
Related: odoo/enterprise#48515
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
@@ -7,7 +7,6 @@ import { serializeDate, serializeDateTime } from "@web/core/l10n/dates";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { x2ManyCommands } from "@web/core/orm_service";
|
||||
import { evaluateBooleanExpr } from "@web/core/py_js/py";
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
import { escape } from "@web/core/utils/strings";
|
||||
import { DataPoint } from "./datapoint";
|
||||
import {
|
||||
@@ -173,7 +172,7 @@ export class Record extends DataPoint {
|
||||
} else {
|
||||
this.model._updateConfig(this.config, { resId: false }, { reload: false });
|
||||
this.dirty = false;
|
||||
this._changes = this._parseServerValues(this._getDefaultValues());
|
||||
this._changes = markRaw(this._parseServerValues(this._getDefaultValues()));
|
||||
this._values = markRaw({});
|
||||
this._textValues = markRaw({});
|
||||
this.data = { ...this._changes };
|
||||
@@ -256,7 +255,12 @@ export class Record extends DataPoint {
|
||||
if (this.model._urgentSave) {
|
||||
return this._update(changes, { save: false }); // save is already scheduled
|
||||
}
|
||||
return this.model.mutex.exec(() => this._update(changes, { save }));
|
||||
return this.model.mutex.exec(async () => {
|
||||
await this._update(changes, { withoutOnchange: save });
|
||||
if (save) {
|
||||
return this._save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
urgentSave() {
|
||||
@@ -283,14 +287,49 @@ export class Record extends DataPoint {
|
||||
}
|
||||
}
|
||||
|
||||
_applyChanges(changes) {
|
||||
_applyChanges(changes, serverChanges = {}) {
|
||||
// We need to generate the undo function before applying the changes
|
||||
const fieldNameChanges = [...Object.keys({ ...changes, ...serverChanges })];
|
||||
const initialTextValues = { ...this._textValues };
|
||||
const initialChanges = { ...this._changes };
|
||||
const initialData = { ...toRaw(this.data) };
|
||||
const invalidFields = toRaw(this._invalidFields);
|
||||
const invalidFieldNames = fieldNameChanges.filter((fieldName) =>
|
||||
invalidFields.has(fieldName)
|
||||
);
|
||||
const undoChanges = () => {
|
||||
for (const fieldName of invalidFieldNames) {
|
||||
this.setInvalidField(fieldName);
|
||||
}
|
||||
Object.assign(this.data, initialData);
|
||||
this._changes = markRaw(initialChanges);
|
||||
Object.assign(this._textValues, initialTextValues);
|
||||
this._setEvalContext();
|
||||
};
|
||||
|
||||
// Apply changes
|
||||
for (const fieldName in changes) {
|
||||
this._changes[fieldName] = changes[fieldName];
|
||||
this.data[fieldName] = changes[fieldName];
|
||||
const change = changes[fieldName];
|
||||
this._changes[fieldName] = change;
|
||||
this.data[fieldName] = change;
|
||||
if (this.fields[fieldName].type === "html") {
|
||||
this._textValues[fieldName] = change === false ? false : change.toString();
|
||||
} else if (["char", "text"].includes(this.fields[fieldName].type)) {
|
||||
this._textValues[fieldName] = change;
|
||||
}
|
||||
}
|
||||
Object.assign(this._textValues, this._getTextValues(changes));
|
||||
|
||||
// Apply server changes
|
||||
const parsedChanges = this._parseServerValues(serverChanges, this.data);
|
||||
for (const fieldName in parsedChanges) {
|
||||
this._changes[fieldName] = parsedChanges[fieldName];
|
||||
this.data[fieldName] = parsedChanges[fieldName];
|
||||
}
|
||||
Object.assign(this._textValues, this._getTextValues(serverChanges));
|
||||
|
||||
this._setEvalContext();
|
||||
this._removeInvalidFields(Object.keys(changes));
|
||||
this._removeInvalidFields(fieldNameChanges);
|
||||
return undoChanges;
|
||||
}
|
||||
|
||||
_applyDefaultValues() {
|
||||
@@ -299,7 +338,7 @@ export class Record extends DataPoint {
|
||||
});
|
||||
const defaultValues = this._getDefaultValues(fieldNames);
|
||||
if (this.isNew) {
|
||||
this._applyChanges(this._parseServerValues(defaultValues));
|
||||
this._applyChanges({}, defaultValues);
|
||||
} else {
|
||||
this._applyValues(defaultValues);
|
||||
}
|
||||
@@ -674,14 +713,6 @@ export class Record extends DataPoint {
|
||||
return parsedValues;
|
||||
}
|
||||
|
||||
async _preprocessChanges(changes) {
|
||||
await Promise.all([
|
||||
this._preprocessMany2oneChanges(changes),
|
||||
this._preprocessReferenceChanges(changes),
|
||||
this._preprocessX2manyChanges(changes),
|
||||
]);
|
||||
}
|
||||
|
||||
async _preprocessReferenceChanges(changes) {
|
||||
const proms = [];
|
||||
for (const [fieldName, value] of Object.entries(changes)) {
|
||||
@@ -812,6 +843,38 @@ export class Record extends DataPoint {
|
||||
}
|
||||
}
|
||||
|
||||
_preprocessPropertiesChanges(changes) {
|
||||
for (const [fieldName, value] of Object.entries(changes)) {
|
||||
const field = this.fields[fieldName];
|
||||
if (field && field.relatedPropertyField) {
|
||||
const [propertyFieldName, propertyName] = field.name.split(".");
|
||||
const propertiesData = this.data[propertyFieldName] || [];
|
||||
if (!propertiesData.find((property) => property.name === propertyName)) {
|
||||
// try to change the value of a properties that has a different parent
|
||||
this.model.notification.add(
|
||||
_t(
|
||||
"This record belongs to a different parent so you can not change this property."
|
||||
),
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
changes[propertyFieldName] = propertiesData.map((property) =>
|
||||
property.name === propertyName ? { ...property, value } : property
|
||||
);
|
||||
delete changes[field.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_preprocessHtmlChanges(changes) {
|
||||
for (const [fieldName, value] of Object.entries(changes)) {
|
||||
if (this.fields[fieldName].type === "html") {
|
||||
changes[fieldName] = value === false ? false : markup(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_removeInvalidFields(fieldNames) {
|
||||
for (const fieldName of fieldNames) {
|
||||
this._invalidFields.delete(fieldName);
|
||||
@@ -911,11 +974,11 @@ export class Record extends DataPoint {
|
||||
}
|
||||
this._setData(records[0]);
|
||||
} else {
|
||||
this._values = { ...this._values, ...this._changes };
|
||||
this._values = markRaw({ ...this._values, ...this._changes });
|
||||
if ("id" in this.activeFields) {
|
||||
this._values.id = records[0].id;
|
||||
}
|
||||
this._changes = {};
|
||||
this._changes = markRaw({});
|
||||
this.data = { ...this._values };
|
||||
this.dirty = false;
|
||||
}
|
||||
@@ -979,63 +1042,57 @@ export class Record extends DataPoint {
|
||||
}
|
||||
}
|
||||
|
||||
async _update(changes, { withoutOnchange, withoutParentUpdate, save } = {}) {
|
||||
async _getOnchangeValues(changes) {
|
||||
const onChangeFields = Object.keys(changes).filter(
|
||||
(fieldName) => this.activeFields[fieldName] && this.activeFields[fieldName].onChange
|
||||
);
|
||||
if (!onChangeFields.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const localChanges = this._getChanges(
|
||||
{ ...this._changes, ...changes },
|
||||
{ withReadonly: true }
|
||||
);
|
||||
if (this.config.relationField) {
|
||||
localChanges[this.config.relationField] = this._parentRecord._getChanges();
|
||||
if (!this._parentRecord.isNew) {
|
||||
localChanges[this.config.relationField].id = this._parentRecord.resId;
|
||||
}
|
||||
}
|
||||
return this.model._onchange(this.config, {
|
||||
changes: localChanges,
|
||||
fieldNames: onChangeFields,
|
||||
evalContext: toRaw(this.evalContext),
|
||||
onError: (e) => {
|
||||
// We apply changes and revert them after to force a render of the Field components
|
||||
const undoChanges = this._applyChanges(changes);
|
||||
undoChanges();
|
||||
throw e;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async _update(changes, { withoutOnchange, withoutParentUpdate } = {}) {
|
||||
this.dirty = true;
|
||||
const prom = this._preprocessChanges(changes);
|
||||
if (prom && !this.model._urgentSave) {
|
||||
const prom = Promise.all([
|
||||
this._preprocessMany2oneChanges(changes),
|
||||
this._preprocessReferenceChanges(changes),
|
||||
this._preprocessX2manyChanges(changes),
|
||||
this._preprocessPropertiesChanges(changes),
|
||||
this._preprocessHtmlChanges(changes),
|
||||
]);
|
||||
if (!this.model._urgentSave) {
|
||||
await prom;
|
||||
}
|
||||
if (this.selected && this.model.multiEdit) {
|
||||
this._applyChanges(changes);
|
||||
return this.model.root._multiSave(this);
|
||||
}
|
||||
for (const [fieldName, value] of Object.entries(changes)) {
|
||||
const field = this.fields[fieldName];
|
||||
if (field && field.relatedPropertyField) {
|
||||
const [propertyFieldName, propertyName] = field.name.split(".");
|
||||
const propertiesData = this.data[propertyFieldName] || [];
|
||||
if (!propertiesData.find((property) => property.name === propertyName)) {
|
||||
// try to change the value of a properties that has a different parent
|
||||
this.model.notification.add(
|
||||
_t(
|
||||
"This record belongs to a different parent so you can not change this property."
|
||||
),
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
changes[propertyFieldName] = propertiesData.map((property) =>
|
||||
property.name === propertyName ? { ...property, value } : property
|
||||
);
|
||||
delete changes[field.name];
|
||||
}
|
||||
}
|
||||
const onChangeFields = Object.keys(changes).filter(
|
||||
(fieldName) => this.activeFields[fieldName] && this.activeFields[fieldName].onChange
|
||||
);
|
||||
if (onChangeFields.length && !this.model._urgentSave && !withoutOnchange && !save) {
|
||||
const localChanges = this._getChanges(
|
||||
{ ...this._changes, ...changes },
|
||||
{ withReadonly: true }
|
||||
);
|
||||
if (this.config.relationField) {
|
||||
localChanges[this.config.relationField] = this._parentRecord._getChanges();
|
||||
if (!this._parentRecord.isNew) {
|
||||
localChanges[this.config.relationField].id = this._parentRecord.resId;
|
||||
}
|
||||
}
|
||||
const otherChanges = await this.model._onchange(this.config, {
|
||||
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));
|
||||
|
||||
let onchangeServerValues = {};
|
||||
if (!this.model._urgentSave && !withoutOnchange) {
|
||||
onchangeServerValues = await this._getOnchangeValues(changes);
|
||||
}
|
||||
// changes inside the record set as value for a many2one field must trigger the onchange,
|
||||
// but can't be considered as changes on the parent record, so here we detect if many2one
|
||||
@@ -1049,20 +1106,16 @@ export class Record extends DataPoint {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(changes).length > 0) {
|
||||
const initialChanges = pick(this.data, ...Object.keys(changes));
|
||||
this._applyChanges(changes);
|
||||
const undoChanges = this._applyChanges(changes, onchangeServerValues);
|
||||
if (Object.keys(changes).length > 0 || Object.keys(onchangeServerValues).length > 0) {
|
||||
try {
|
||||
await this._onUpdate({ withoutParentUpdate });
|
||||
} catch (e) {
|
||||
this._applyChanges(initialChanges);
|
||||
undoChanges();
|
||||
throw e;
|
||||
}
|
||||
await this.model.hooks.onRecordChanged(this, this._getChanges());
|
||||
}
|
||||
if (save) {
|
||||
return this._save();
|
||||
}
|
||||
}
|
||||
|
||||
_updateChildrenContext() {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { standardFieldProps } from "../standard_field_props";
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { CheckBox } from "@web/core/checkbox/checkbox";
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useRecordObserver } from "@web/model/relational_model/utils";
|
||||
import { standardFieldProps } from "../standard_field_props";
|
||||
|
||||
export class BooleanField extends Component {
|
||||
static template = "web.BooleanField";
|
||||
@@ -14,10 +14,18 @@ export class BooleanField extends Component {
|
||||
...standardFieldProps,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({});
|
||||
useRecordObserver((record) => {
|
||||
this.state.value = record.data[this.props.name];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} newValue
|
||||
*/
|
||||
onChange(newValue) {
|
||||
this.state.value = newValue;
|
||||
this.props.record.update({ [this.props.name]: newValue });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.BooleanField">
|
||||
<CheckBox id="props.id" value="props.record.data[props.name] or false" className="'d-inline-block'" disabled="props.readonly" onChange.bind="onChange" />
|
||||
<CheckBox id="props.id" value="state.value" className="'d-inline-block'" disabled="props.readonly" onChange.bind="onChange" />
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
||||
@@ -12,6 +12,7 @@ export class BooleanToggleField extends BooleanField {
|
||||
};
|
||||
|
||||
async onChange(newValue) {
|
||||
this.state.value = newValue;
|
||||
const changes = { [this.props.name]: newValue };
|
||||
await this.props.record.update(changes, { save: this.props.autosave });
|
||||
}
|
||||
|
||||
@@ -273,4 +273,34 @@ QUnit.module("Fields", (hooks) => {
|
||||
"checkbox should still be disabled"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("onchange return value before toggle checkbox", async function (assert) {
|
||||
serverData.models.partner.onchanges = {
|
||||
bar(obj) {
|
||||
obj.bar = true;
|
||||
},
|
||||
};
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `<form><field name="bar"/></form>`,
|
||||
});
|
||||
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_boolean input:checked",
|
||||
"checkbox should still be checked"
|
||||
);
|
||||
|
||||
await click(target, ".o_field_boolean .o-checkbox");
|
||||
await nextTick();
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_boolean input:checked",
|
||||
"checkbox should still be checked"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -795,6 +795,30 @@ QUnit.module("Fields", (hooks) => {
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("onchange return value before editing input", async function (assert) {
|
||||
serverData.models.partner.onchanges = {
|
||||
foo(obj) {
|
||||
obj.foo = "yop";
|
||||
},
|
||||
};
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="foo" />
|
||||
</form>`,
|
||||
});
|
||||
|
||||
assert.strictEqual(target.querySelector("[name='foo'] input").value, "yop");
|
||||
|
||||
await editInput(target, "[name='foo'] input", "tralala");
|
||||
assert.strictEqual(target.querySelector("[name='foo'] input").value, "yop");
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"input field: change value before pending onchange renaming",
|
||||
async function (assert) {
|
||||
@@ -1050,4 +1074,40 @@ QUnit.module("Fields", (hooks) => {
|
||||
"Placeholder"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"char field: correct value is used to evaluate the modifiers",
|
||||
async function (assert) {
|
||||
serverData.models.partner.onchanges = {
|
||||
foo: (obj) => {
|
||||
if (obj.foo === "a") {
|
||||
obj.display_name = false;
|
||||
} else if (obj.foo === "b") {
|
||||
obj.display_name = "";
|
||||
}
|
||||
},
|
||||
};
|
||||
serverData.models.partner.records[0].foo = false;
|
||||
serverData.models.partner.records[0].display_name = false;
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
serverData,
|
||||
resId: 1,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="foo" />
|
||||
<field name="display_name" invisible="'' == display_name"/>
|
||||
</form>`,
|
||||
});
|
||||
assert.containsOnce(target, "[name='display_name'] input");
|
||||
|
||||
await editInput(target, "[name='foo'] input", "a");
|
||||
assert.containsOnce(target, "[name='display_name'] input");
|
||||
|
||||
await editInput(target, "[name='foo'] input", "b");
|
||||
assert.containsNone(target, "[name='display_name'] input");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -353,4 +353,41 @@ QUnit.module("Fields", ({ beforeEach }) => {
|
||||
await clickSave(target);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"html field: correct value is used to evaluate the modifiers",
|
||||
async function (assert) {
|
||||
serverData.models.partner.fields.foo = { string: "foo", type: "char" };
|
||||
serverData.models.partner.onchanges = {
|
||||
foo: (obj) => {
|
||||
if (obj.foo === "a") {
|
||||
obj.txt = false;
|
||||
} else if (obj.foo === "b") {
|
||||
obj.txt = "";
|
||||
}
|
||||
},
|
||||
};
|
||||
serverData.models.partner.records[0].foo = false;
|
||||
serverData.models.partner.records[0].txt = false;
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
serverData,
|
||||
resId: 1,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="foo" />
|
||||
<field name="txt" invisible="'' == txt"/>
|
||||
</form>`,
|
||||
});
|
||||
assert.containsOnce(target, "[name='txt'] textarea");
|
||||
|
||||
await editInput(target, "[name='foo'] input", "a");
|
||||
assert.containsOnce(target, "[name='txt'] textarea");
|
||||
|
||||
await editInput(target, "[name='foo'] input", "b");
|
||||
assert.containsNone(target, "[name='txt'] textarea");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8930,7 +8930,7 @@ QUnit.module("Fields", (hooks) => {
|
||||
);
|
||||
|
||||
QUnit.test("display correct value after validation error", async function (assert) {
|
||||
assert.expect(5);
|
||||
assert.expect(4);
|
||||
|
||||
serviceRegistry.add("error", errorService);
|
||||
function validationHandler(env, error, originalError) {
|
||||
@@ -8986,8 +8986,10 @@ 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");
|
||||
assert.hasClass(target.querySelector(".o_field_widget[name=turtles]"), "o_field_invalid");
|
||||
assert.ok(target.querySelector(".o_form_button_save").disabled);
|
||||
|
||||
// we make sure here that when we save, the values are the current
|
||||
// values displayed in the field.
|
||||
await clickSave(target);
|
||||
});
|
||||
|
||||
QUnit.test("propagate context to sub views without default_* keys", async function (assert) {
|
||||
|
||||
@@ -6362,9 +6362,9 @@ QUnit.module("Views", (hooks) => {
|
||||
document.body.querySelector(".modal-body").textContent,
|
||||
"Some business message"
|
||||
);
|
||||
assert.hasClass(
|
||||
target.querySelector('.o_field_widget[name="int_field"]'),
|
||||
"o_field_invalid"
|
||||
assert.strictEqual(
|
||||
target.querySelector('.o_field_widget[name="int_field"] input').value,
|
||||
"9"
|
||||
);
|
||||
|
||||
await click(target.querySelector(".modal .btn-close"));
|
||||
|
||||
Reference in New Issue
Block a user