[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:
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("display_name == \\"take\\"",__comp__.props.record.evalContext)" />
|
||||
<div t-if="!__comp__.evaluateBooleanExpr("display_name == \\"take\\"",__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("display_name == 'take'",__comp__.props.record.evalContext)" />
|
||||
<div t-if="!__comp__.evaluateBooleanExpr("display_name == 'take'",__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("field == 'value'",__comp__.props.record.evalContext)" t-ref="compiled_view_root"/></t>`;
|
||||
const expected = `<t><div class="myNode" t-if="( myCondition or myOtherCondition ) and !__comp__.evaluateBooleanExpr("field == 'value'",__comp__.props.record.evalContextWithVirtualIds)" t-ref="compiled_view_root"/></t>`;
|
||||
assert.areEquivalent(compileTemplate(arch), expected);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user