[FIX] web: use evalContextWithVirtualIds for client expression

Before this commit, all python expressions in js were evaluated with
the same evalContext. Unfortunately, this is not possible for expressions
based on an x2many. The value of the x2many depends on whether the
expression will be sent to the server or not.

All expressions that are only used client-side, such as modifiers (readonly,
required, invisible), decorations, etc., must take virtual records into account.
For example, I define a button that must be visible when I have at least
one record in my x2many. So when I create my virtual record, I want
the button to become visible and not have to wait for the record to be
actually created.

For expressions sent to the server, such as domains and contexts,
we don't want them to take virtual records into account, as these are
not known by the server and could cause crashes.

Solution:
We have evalContext for expressions sent to the server
and evalContextWithVirtualIds for client-side expressions.

closes odoo/odoo#133718

Related: odoo/enterprise#46604
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
FrancoisGe
2023-09-01 15:09:49 +00:00
parent dd4b3c3a66
commit 61307c162d
14 changed files with 110 additions and 59 deletions
@@ -19,7 +19,7 @@ export class ProjectSharingListRenderer extends ListRenderer {
const allColumns = [];
const firstRecord = this.props.list.records[0];
for (const column of columns) {
if (evaluateBooleanExpr(column.column_invisible, firstRecord.evalContext)) {
if (evaluateBooleanExpr(column.column_invisible, firstRecord.evalContextWithVirtualIds)) {
continue;
}
allColumns.push(column);
@@ -46,8 +46,14 @@ export class Record extends DataPoint {
return parentRecord.evalContext;
},
};
this.evalContextWithVirtualIds = {
get parent() {
return parentRecord.evalContextWithVirtualIds;
},
};
} else {
this.evalContext = {};
this.evalContextWithVirtualIds = {};
}
const missingFields = this.fieldNames.filter((fieldName) => !(fieldName in data));
data = { ...this._getDefaultValues(missingFields), ...data };
@@ -384,14 +390,21 @@ export class Record extends DataPoint {
_computeDataContext() {
const dataContext = {};
const x2manyDataContext = {
withVirtualIds: {},
withoutVirtualIds: {},
};
const data = toRaw(this.data);
for (const fieldName in data) {
const value = data[fieldName];
const field = this.fields[fieldName];
if (["char", "text", "html"].includes(field.type)) {
dataContext[fieldName] = this._textValues[fieldName];
} else if (["one2many", "many2many"].includes(field.type)) {
dataContext[fieldName] = value.currentIds.filter((id) => typeof id === "number");
} else if (field.type === "one2many" || field.type === "many2many") {
x2manyDataContext.withVirtualIds[fieldName] = value.currentIds;
x2manyDataContext.withoutVirtualIds[fieldName] = value.currentIds.filter(
(id) => typeof id === "number"
);
} else if (value && field.type === "date") {
dataContext[fieldName] = serializeDate(value);
} else if (value && field.type === "datetime") {
@@ -409,7 +422,10 @@ export class Record extends DataPoint {
}
}
dataContext.id = this.resId || false;
return dataContext;
return {
withVirtualIds: { ...dataContext, ...x2manyDataContext.withVirtualIds },
withoutVirtualIds: { ...dataContext, ...x2manyDataContext.withoutVirtualIds },
};
}
_createStaticListDatapoint(data, fieldName) {
@@ -558,17 +574,17 @@ export class Record extends DataPoint {
_isInvisible(fieldName) {
const invisible = this.activeFields[fieldName].invisible;
return invisible ? evaluateBooleanExpr(invisible, this.evalContext) : false;
return invisible ? evaluateBooleanExpr(invisible, this.evalContextWithVirtualIds) : false;
}
_isReadonly(fieldName) {
const readonly = this.activeFields[fieldName].readonly;
return readonly ? evaluateBooleanExpr(readonly, this.evalContext) : false;
return readonly ? evaluateBooleanExpr(readonly, this.evalContextWithVirtualIds) : false;
}
_isRequired(fieldName) {
const required = this.activeFields[fieldName].required;
return required ? evaluateBooleanExpr(required, this.evalContext) : false;
return required ? evaluateBooleanExpr(required, this.evalContextWithVirtualIds) : false;
}
async _load(nextConfig = {}) {
@@ -871,14 +887,16 @@ export class Record extends DataPoint {
* be uselessly re-rendered if we replace it by a brand new object.
*/
_setEvalContext() {
Object.assign(this.evalContext, {
const evalContext = {
...this.context,
active_id: this.resId || false,
active_ids: this.resId ? [this.resId] : [],
active_model: this.resModel,
current_company_id: this.model.company.currentCompany.id,
...this._computeDataContext(),
});
};
const dataContext = this._computeDataContext();
Object.assign(this.evalContext, evalContext, dataContext.withoutVirtualIds);
Object.assign(this.evalContextWithVirtualIds, evalContext, dataContext.withVirtualIds);
for (const [fieldName, value] of Object.entries(toRaw(this.data))) {
if (
@@ -34,7 +34,7 @@ export class CalendarCommonPopover extends Component {
}
isInvisible(fieldNode, record) {
return evaluateBooleanExpr(fieldNode.invisible, record.evalContext);
return evaluateBooleanExpr(fieldNode.invisible, record.evalContextWithVirtualIds);
}
computeDateTimeAndDuration() {
+1 -1
View File
@@ -220,7 +220,7 @@ class SetDefaultDialog extends Component {
const valueDisplayed = this.display(fieldInfo, this.fieldsValues[fieldName]);
const value = valueDisplayed[0];
const displayed = valueDisplayed[1];
const evalContext = this.props.record.evalContext;
const evalContext = this.props.record.evalContextWithVirtualIds;
// ignore fields which are empty, invisible, readonly, o2m or m2m
if (
!value ||
@@ -26,7 +26,7 @@ export class BadgeField extends Component {
}
get classFromDecoration() {
const evalContext = this.props.record.evalContext;
const evalContext = this.props.record.evalContextWithVirtualIds;
for (const decorationName in this.props.decorations) {
if (evaluateBooleanExpr(this.props.decorations[decorationName], evalContext)) {
return `text-bg-${decorationName}`;
@@ -36,7 +36,12 @@ class CopyClipboardField extends Component {
return this.props.record.fields[this.props.name].type;
}
get disabled() {
return this.props.disabledExpr ? evaluateBooleanExpr(this.props.disabledExpr, this.props.record.evalContext) : false;
return this.props.disabledExpr
? evaluateBooleanExpr(
this.props.disabledExpr,
this.props.record.evalContextWithVirtualIds
)
: false;
}
}
+16 -14
View File
@@ -5,11 +5,7 @@ import { evaluateExpr, evaluateBooleanExpr } from "@web/core/py_js/py";
import { registry } from "@web/core/registry";
import { utils } from "@web/core/ui/ui_service";
import { getFieldContext } from "@web/model/relational_model/utils";
import {
archParseBoolean,
getClassNameFromDecoration,
X2M_TYPES,
} from "@web/views/utils";
import { archParseBoolean, getClassNameFromDecoration, X2M_TYPES } from "@web/views/utils";
import { getTooltipInfo } from "./field_tooltip";
import { Component, xml } from "@odoo/owl";
@@ -43,8 +39,8 @@ export function getFieldFromRegistry(fieldType, widget, viewType, jsClass) {
}
export function fieldVisualFeedback(field, record, fieldName, fieldInfo) {
const readonly = evaluateBooleanExpr(fieldInfo.readonly, record.evalContext);
const required = evaluateBooleanExpr(fieldInfo.required, record.evalContext);
const readonly = evaluateBooleanExpr(fieldInfo.readonly, record.evalContextWithVirtualIds);
const required = evaluateBooleanExpr(fieldInfo.required, record.evalContextWithVirtualIds);
const inEdit = record.isInEdition;
let empty = !record.isNew;
@@ -154,9 +150,11 @@ export class Field extends Component {
// only handle the text-decoration.
if (fieldInfo && fieldInfo.decorations) {
const { decorations } = fieldInfo;
const evalContext = record.evalContext;
for (const decoName in decorations) {
const value = evaluateBooleanExpr(decorations[decoName], evalContext);
const value = evaluateBooleanExpr(
decorations[decoName],
record.evalContextWithVirtualIds
);
classNames[getClassNameFromDecoration(decoName)] = value;
}
}
@@ -174,9 +172,10 @@ export class Field extends Component {
let propsFromNode = {};
if (this.props.fieldInfo) {
const evalContext = record.getEvalContext?.(false) || record.evalContext;
let fieldInfo = this.props.fieldInfo;
readonly = readonly || evaluateBooleanExpr(fieldInfo.readonly, evalContext);
readonly =
readonly ||
evaluateBooleanExpr(fieldInfo.readonly, record.evalContextWithVirtualIds);
if (this.field.extractProps) {
if (this.props.attrs) {
@@ -191,7 +190,7 @@ export class Field extends Component {
return getFieldContext(record, fieldInfo.name, fieldInfo.context);
},
domain() {
const evalContext = record.getEvalContext?.(true) || record.evalContext;
const evalContext = record.evalContext;
if (fieldInfo.domain) {
return new Domain(evaluateExpr(fieldInfo.domain, evalContext)).toList();
}
@@ -200,7 +199,10 @@ export class Field extends Component {
? new Domain(evaluateExpr(domain, evalContext)).toList()
: domain || [];
},
required: evaluateBooleanExpr(fieldInfo.required, evalContext),
required: evaluateBooleanExpr(
fieldInfo.required,
record.evalContextWithVirtualIds
),
readonly: readonly,
};
propsFromNode = this.field.extractProps(fieldInfo, dynamicInfo);
@@ -296,7 +298,7 @@ Field.parseFieldNode = function (node, models, modelName, viewType, jsClass) {
}
}
if (name === "id") {
fieldInfo.readonly = 'True';
fieldInfo.readonly = "True";
}
if (widget === "handle") {
@@ -166,7 +166,7 @@ export class Many2OneField extends Component {
return makeContext([context], evalContext);
}
get classFromDecoration() {
const evalContext = this.props.record.evalContext;
const evalContext = this.props.record.evalContextWithVirtualIds;
for (const decorationName in this.props.decorations) {
if (evaluateBooleanExpr(this.props.decorations[decorationName], evalContext)) {
return `text-${decorationName}`;
@@ -146,7 +146,7 @@ export class FormCompiler extends ViewCompiler {
} else {
isVisibleExpr = `!__comp__.evaluateBooleanExpr(${JSON.stringify(
invisible
)},__comp__.props.record.evalContext)`;
)},__comp__.props.record.evalContextWithVirtualIds)`;
}
const mainSlot = createElement("t", {
"t-set-slot": `slot_${slotId++}`,
@@ -349,7 +349,7 @@ export class FormCompiler extends ViewCompiler {
} else {
isVisibleExpr = `!__comp__.evaluateBooleanExpr(${JSON.stringify(
invisible
)},__comp__.props.record.evalContext)`;
)},__comp__.props.record.evalContextWithVirtualIds)`;
}
mainSlot.setAttribute("isVisible", isVisibleExpr);
if (itemSpan > 0) {
@@ -549,7 +549,7 @@ export class FormCompiler extends ViewCompiler {
} else {
isVisibleExpr = `!__comp__.evaluateBooleanExpr(${JSON.stringify(
invisible
)},__comp__.props.record.evalContext)`;
)},__comp__.props.record.evalContextWithVirtualIds)`;
}
pageSlot.setAttribute("isVisible", isVisibleExpr);
@@ -814,7 +814,9 @@ export class ListRenderer extends Component {
getRowClass(record) {
// classnames coming from decorations
const classNames = this.props.archInfo.decorations
.filter((decoration) => evaluateBooleanExpr(decoration.condition, record.evalContext))
.filter((decoration) =>
evaluateBooleanExpr(decoration.condition, record.evalContextWithVirtualIds)
)
.map((decoration) => decoration.class);
if (record.selected) {
classNames.push("table-info");
@@ -858,7 +860,7 @@ export class ListRenderer extends Component {
}
const classNames = [...this.cellClassByColumn[column.id]];
if (column.type === "field") {
if (evaluateBooleanExpr(column.required, record.evalContext)) {
if (evaluateBooleanExpr(column.required, record.evalContextWithVirtualIds)) {
classNames.push("o_required_modifier");
}
if (record.isFieldInvalid(column.name)) {
@@ -873,7 +875,9 @@ export class ListRenderer extends Component {
// only handle the text-decoration.
const { decorations } = column;
for (const decoName in decorations) {
if (evaluateBooleanExpr(decorations[decoName], record.evalContext)) {
if (
evaluateBooleanExpr(decorations[decoName], record.evalContextWithVirtualIds)
) {
classNames.push(getClassNameFromDecoration(decoName));
}
}
@@ -895,7 +899,7 @@ export class ListRenderer extends Component {
return !!(
this.isRecordReadonly(record) ||
(column.relatedPropertyField && record.selected && record.model.multiEdit) ||
evaluateBooleanExpr(column.readonly, record.evalContext)
evaluateBooleanExpr(column.readonly, record.evalContextWithVirtualIds)
);
}
@@ -928,7 +932,7 @@ export class ListRenderer extends Component {
}
evalInvisible(invisible, record) {
return evaluateBooleanExpr(invisible, record.evalContext);
return evaluateBooleanExpr(invisible, record.evalContextWithVirtualIds);
}
evalColumnInvisible(columnInvisible) {
+1 -1
View File
@@ -234,7 +234,7 @@ export class ViewCompiler {
const recordExpr = params.recordExpr || "__comp__.props.record";
let isVisileExpr = `!__comp__.evaluateBooleanExpr(${JSON.stringify(
invisible
)},${recordExpr}.evalContext)`;
)},${recordExpr}.evalContextWithVirtualIds)`;
if (compiled.hasAttribute("t-if")) {
const formerTif = compiled.getAttribute("t-if");
isVisileExpr = `( ${formerTif} ) and ${isVisileExpr}`;
@@ -38,13 +38,15 @@ export class Widget extends Component {
}
get widgetProps() {
const record = this.props.record;
const evalContext = record.evalContext;
let readonlyFromModifiers = false;
let propsFromNode = {};
if (this.props.widgetInfo) {
const widgetInfo = this.props.widgetInfo;
readonlyFromModifiers = evaluateBooleanExpr(widgetInfo.attrs.readonly, evalContext);
readonlyFromModifiers = evaluateBooleanExpr(
widgetInfo.attrs.readonly,
record.evalContextWithVirtualIds
);
propsFromNode = this.widget.extractProps ? this.widget.extractProps(widgetInfo) : {};
}
@@ -4361,14 +4361,7 @@ QUnit.module("Fields", (hooks) => {
await clickSave(target);
assert.containsNone(target, "tr.o_data_row");
assert.verifySteps([
"get_views",
"web_read",
"onchange",
"onchange",
"write",
"web_read",
]);
assert.verifySteps(["get_views", "web_read", "onchange", "onchange", "write", "web_read"]);
});
QUnit.test("discard O2M field with close button", async function (assert) {
@@ -4823,14 +4816,7 @@ QUnit.module("Fields", (hooks) => {
"9"
);
assert.verifySteps([
"get_views",
"web_read",
"onchange",
"onchange",
"write",
"web_read",
]);
assert.verifySteps(["get_views", "web_read", "onchange", "onchange", "write", "web_read"]);
});
QUnit.test("editable o2m, pressing ESC discard current changes", async function (assert) {
@@ -14064,4 +14050,38 @@ QUnit.module("Fields", (hooks) => {
await clickSave(target.querySelector(".o_dialog"));
await clickSave(target);
});
QUnit.test("modifiers based on x2many", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="p" >
<tree editable="bottom">
<field name="foo"/>
</tree>
</field>
<field name="display_name" readonly="p"/>
<field name="int_field" required="p"/>
<button name="abc" string="Do it" class="my_button" invisible="not p"/>
</form>`,
resId: 1,
});
assert.containsNone(target, "button.my_button");
assert.containsNone(target, "[name='display_name'].o_readonly_modifier");
assert.containsNone(target, "[name='int_field'].o_required_modifier");
await addRow(target);
await editInput(target, "[name='foo'] input", "Test");
assert.containsOnce(target, "button.my_button");
assert.containsOnce(target, "[name='display_name'].o_readonly_modifier");
assert.containsOnce(target, "[name='int_field'].o_required_modifier");
await click(target, "button.fa-trash-o");
assert.containsNone(target, "button.my_button");
assert.containsNone(target, "[name='display_name'].o_readonly_modifier");
assert.containsNone(target, "[name='int_field'].o_required_modifier");
});
});
@@ -239,7 +239,7 @@ QUnit.module("Form Compiler", (hooks) => {
const expected = /*xml*/ `
<div class="visible3" />
<div t-if="!__comp__.evaluateBooleanExpr(&quot;display_name == \\&quot;take\\&quot;&quot;,__comp__.props.record.evalContext)" />
<div t-if="!__comp__.evaluateBooleanExpr(&quot;display_name == \\&quot;take\\&quot;&quot;,__comp__.props.record.evalContextWithVirtualIds)" />
`;
assert.areContentEquivalent(compileTemplate(arch), expected);
@@ -255,7 +255,7 @@ QUnit.module("Form Compiler", (hooks) => {
const expected = /*xml*/ `
<div class="visible3" />
<div t-if="!__comp__.evaluateBooleanExpr(&quot;display_name == 'take'&quot;,__comp__.props.record.evalContext)" />
<div t-if="!__comp__.evaluateBooleanExpr(&quot;display_name == 'take'&quot;,__comp__.props.record.evalContextWithVirtualIds)" />
`;
assert.areContentEquivalent(compileTemplate(arch), expected);
});
@@ -484,7 +484,7 @@ QUnit.module("Form Renderer", (hooks) => {
const arch = `<myNode invisible="field == 'value'" />`;
const expected = `<t><div class="myNode" t-if="( myCondition or myOtherCondition ) and !__comp__.evaluateBooleanExpr(&quot;field == 'value'&quot;,__comp__.props.record.evalContext)" t-ref="compiled_view_root"/></t>`;
const expected = `<t><div class="myNode" t-if="( myCondition or myOtherCondition ) and !__comp__.evaluateBooleanExpr(&quot;field == 'value'&quot;,__comp__.props.record.evalContextWithVirtualIds)" t-ref="compiled_view_root"/></t>`;
assert.areEquivalent(compileTemplate(arch), expected);
});
});