diff --git a/addons/account/static/tests/section_and_note_tests.js b/addons/account/static/tests/section_and_note_tests.js index 5566156d412..958b65d604c 100644 --- a/addons/account/static/tests/section_and_note_tests.js +++ b/addons/account/static/tests/section_and_note_tests.js @@ -73,7 +73,7 @@ QUnit.module('section_and_note', (hooks) => { - + diff --git a/addons/mail/static/tests/web/fields/onchange_on_keydown_tests.js b/addons/mail/static/tests/web/fields/onchange_on_keydown_tests.js index 36e54cad037..9375f7c63c7 100644 --- a/addons/mail/static/tests/web/fields/onchange_on_keydown_tests.js +++ b/addons/mail/static/tests/web/fields/onchange_on_keydown_tests.js @@ -96,7 +96,7 @@ QUnit.test( resId: 1, arch: `
- + `, mockRPC(route, params) { diff --git a/addons/mass_mailing/static/tests/mass_mailing_favourite_filter_tests.js b/addons/mass_mailing/static/tests/mass_mailing_favourite_filter_tests.js index dac6570088c..41f76ebc971 100644 --- a/addons/mass_mailing/static/tests/mass_mailing_favourite_filter_tests.js +++ b/addons/mass_mailing/static/tests/mass_mailing_favourite_filter_tests.js @@ -459,7 +459,7 @@ QUnit.module('favorite filter widget', (hooks) => { - + diff --git a/addons/mass_mailing/static/tests/mass_mailing_html_tests.js b/addons/mass_mailing/static/tests/mass_mailing_html_tests.js index e27dd4c0deb..03e7c549e70 100644 --- a/addons/mass_mailing/static/tests/mass_mailing_html_tests.js +++ b/addons/mass_mailing/static/tests/mass_mailing_html_tests.js @@ -109,7 +109,7 @@ QUnit.module('field html', (hooks) => { arch: `
- + `, }); diff --git a/addons/project/static/src/project_sharing/views/list/list_renderer.js b/addons/project/static/src/project_sharing/views/list/list_renderer.js index 043452befbf..b584239ad8b 100644 --- a/addons/project/static/src/project_sharing/views/list/list_renderer.js +++ b/addons/project/static/src/project_sharing/views/list/list_renderer.js @@ -1,6 +1,6 @@ /** @odoo-module */ -import { evalDomain } from "@web/core/domain"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { ListRenderer } from "@web/views/list/list_renderer"; const { onWillUpdateProps } = owl; @@ -19,14 +19,8 @@ export class ProjectSharingListRenderer extends ListRenderer { const allColumns = []; const firstRecord = this.props.list.records[0]; for (const column of columns) { - if ( - column.modifiers.column_invisible && - column.modifiers.column_invisible instanceof Array - ) { - const result = evalDomain(column.modifiers.column_invisible, firstRecord.evalContext); - if (result) { - continue; - } + if (evaluateBooleanExpr(column.column_invisible, firstRecord.evalContext)) { + continue; } allColumns.push(column); } diff --git a/addons/project/static/src/views/project_task_kanban/project_task_kanban_renderer.js b/addons/project/static/src/views/project_task_kanban/project_task_kanban_renderer.js index b3591f2b290..836a2550a62 100644 --- a/addons/project/static/src/views/project_task_kanban/project_task_kanban_renderer.js +++ b/addons/project/static/src/views/project_task_kanban/project_task_kanban_renderer.js @@ -16,8 +16,7 @@ export class ProjectTaskKanbanRenderer extends KanbanRenderer { let canMoveRecords = super.canMoveRecords; if (!canMoveRecords && this.canResequenceRecords && this.props.list.isGroupedByPersonalStages) { const { groupByField } = this.props.list; - const { modifiers } = groupByField; - canMoveRecords = !(modifiers && modifiers.readonly); + canMoveRecords = groupByField.readonly !== "True"; } return canMoveRecords; } @@ -25,9 +24,8 @@ export class ProjectTaskKanbanRenderer extends KanbanRenderer { get canResequenceGroups() { let canResequenceGroups = super.canResequenceGroups; if (!canResequenceGroups && this.props.list.isGroupedByPersonalStages) { - const { modifiers } = this.props.list.groupByField; const { groupsDraggable } = this.props.archInfo; - canResequenceGroups = groupsDraggable && !(modifiers && modifiers.readonly); + canResequenceGroups = groupsDraggable && groupsDraggable.readonly !== "True"; } return canResequenceGroups; } diff --git a/addons/resource/static/tests/components/section_one2many_field_tests.js b/addons/resource/static/tests/components/section_one2many_field_tests.js index 56cb49e773b..b11864f51cb 100644 --- a/addons/resource/static/tests/components/section_one2many_field_tests.js +++ b/addons/resource/static/tests/components/section_one2many_field_tests.js @@ -58,7 +58,7 @@ QUnit.module("SectionOneToManyField", (hooks) => {
- + diff --git a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js index e9ba0aba7f0..69c26a6537e 100644 --- a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js +++ b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js @@ -93,6 +93,10 @@ registry.category("web_tour.tours").add('sale_timesheet_tour', { }, { trigger: 'div[name="timesheet_ids"] td.o_field_x2many_list_row_add a[role="button"]', content: 'Click on Add a line to create a new timesheet into the task.', +}, { + trigger: '.o_field_x2many div[name="name"] input', + content: 'Enter a description for this timesheet', + run: 'text work', }, { trigger: 'div[name="unit_amount"] input', content: 'Enter one hour for this timesheet', diff --git a/addons/survey/static/tests/components/question_page_list_renderer_tests.js b/addons/survey/static/tests/components/question_page_list_renderer_tests.js index ec6def0905a..234ec398917 100644 --- a/addons/survey/static/tests/components/question_page_list_renderer_tests.js +++ b/addons/survey/static/tests/components/question_page_list_renderer_tests.js @@ -76,7 +76,7 @@ QUnit.module("QuestionPageListRenderer", (hooks) => { - + @@ -108,7 +108,7 @@ QUnit.module("QuestionPageListRenderer", (hooks) => { - + @@ -147,7 +147,7 @@ QUnit.module("QuestionPageListRenderer", (hooks) => { - + @@ -184,7 +184,7 @@ QUnit.module("QuestionPageListRenderer", (hooks) => { - + diff --git a/addons/web/static/src/core/py_js/py.js b/addons/web/static/src/core/py_js/py.js index 50c41eaa7e4..423803c77cf 100644 --- a/addons/web/static/src/core/py_js/py.js +++ b/addons/web/static/src/core/py_js/py.js @@ -33,6 +33,32 @@ export function parseExpr(expr) { * @returns {any} */ export function evaluateExpr(expr, context = {}) { - const ast = parseExpr(expr); - return evaluate(ast, context); + let ast; + try { + ast = parseExpr(expr); + } catch (error) { + throw new EvalError(`Can not parse python expression: (${expr})\nError: ${error.message}`); + } + try { + return evaluate(ast, context); + } catch (error) { + throw new EvalError(`Can not evaluate python expression: (${expr})\nError: ${error.message}`); + } +} + +/** + * Evaluates a python expression to return a boolean. + * + * @param {string} expr + * @param {Object} [context] + * @returns {any} + */ +export function evaluateBooleanExpr(expr, context = {}) { + if (!expr || expr === 'False' || expr === '0') { + return false; + } + if (expr === 'True' || expr === '1') { + return true; + } + return evaluateExpr(`bool(${expr})`, context); } diff --git a/addons/web/static/src/core/py_js/py_builtin.js b/addons/web/static/src/core/py_js/py_builtin.js index bc57a9b4e21..f70719fab70 100644 --- a/addons/web/static/src/core/py_js/py_builtin.js +++ b/addons/web/static/src/core/py_js/py_builtin.js @@ -16,12 +16,15 @@ export const BUILTINS = { case "boolean": return value; case "object": - if (value === null) { + if (value === null || value === undefined) { return false; } if (value.isTrue) { return value.isTrue(); } + if (value instanceof Array) { + return !!value.length; + } return true; } return true; diff --git a/addons/web/static/src/core/py_js/py_interpreter.js b/addons/web/static/src/core/py_js/py_interpreter.js index c08b02817cb..2aa77399016 100644 --- a/addons/web/static/src/core/py_js/py_interpreter.js +++ b/addons/web/static/src/core/py_js/py_interpreter.js @@ -136,6 +136,9 @@ function isIn(left, right) { if (typeof right === "string" && typeof left === "string") { return right.includes(left); } + if (typeof right === "object") { + return left in right; + } return false; } @@ -261,6 +264,15 @@ const DICT = { }, }; +const STRING = { + lower(str) { + return () => str.toLowerCase(); + }, + upper(str) { + return () => str.toUpperCase(); + }, +}; + // ----------------------------------------------------------------------------- // Evaluate function // ----------------------------------------------------------------------------- @@ -274,14 +286,16 @@ export function evaluate(ast, context = {}) { const dicts = new Set(); let pyContext; const evalContext = Object.create(context); - Object.defineProperty(evalContext, "context", { - get() { - if (!pyContext) { - pyContext = toPyDict(context); - } - return pyContext; - }, - }); + if (!evalContext.context) { + Object.defineProperty(evalContext, "context", { + get() { + if (!pyContext) { + pyContext = toPyDict(context); + } + return pyContext; + }, + }); + } /** * @param {AST} ast @@ -362,6 +376,12 @@ export function evaluate(ast, context = {}) { // this is a dictionary => need to apply dict methods return DICT[ast.key](left); } + if (typeof left === 'string') { + return STRING[ast.key](left); + } + if (ast.key == 'get' && typeof left === 'object') { + return DICT[ast.key](toPyDict(left)); + } const result = left[ast.key]; if (typeof result === "function" && !isConstructor(result)) { return result.bind(left); diff --git a/addons/web/static/src/model/relational_model/record.js b/addons/web/static/src/model/relational_model/record.js index da92ee18225..9d3579661aa 100644 --- a/addons/web/static/src/model/relational_model/record.js +++ b/addons/web/static/src/model/relational_model/record.js @@ -2,7 +2,8 @@ import { markRaw, markup, toRaw } from "@odoo/owl"; import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; -import { Domain, evalDomain } from "@web/core/domain"; +import { Domain } from "@web/core/domain"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { serializeDate, serializeDateTime } from "@web/core/l10n/dates"; import { _t } from "@web/core/l10n/translation"; import { x2ManyCommands } from "@web/core/orm_service"; @@ -541,17 +542,17 @@ export class Record extends DataPoint { _isInvisible(fieldName) { const invisible = this.activeFields[fieldName].invisible; - return invisible ? evalDomain(invisible, this.evalContext) : false; + return invisible ? evaluateBooleanExpr(invisible, this.evalContext) : false; } _isReadonly(fieldName) { const readonly = this.activeFields[fieldName].readonly; - return readonly ? evalDomain(readonly, this.evalContext) : false; + return readonly ? evaluateBooleanExpr(readonly, this.evalContext) : false; } _isRequired(fieldName) { const required = this.activeFields[fieldName].required; - return required ? evalDomain(required, this.evalContext) : false; + return required ? evaluateBooleanExpr(required, this.evalContext) : false; } async _load(nextConfig = {}) { 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 f3472207d21..386112892fa 100644 --- a/addons/web/static/src/model/relational_model/static_list.js +++ b/addons/web/static/src/model/relational_model/static_list.js @@ -502,7 +502,8 @@ export class StaticList extends DataPoint { const changes = {}; for (const fieldName in command[2]) { if (["one2many", "many2many"].includes(this.fields[fieldName].type)) { - if (record.activeFields[fieldName].invisible) { + const invisible = record.activeFields[fieldName].invisible; + if (invisible === "True" || invisible === "1") { if (!(command[1] in this._unknownRecordCommands)) { this._unknownRecordCommands[command[1]] = []; } diff --git a/addons/web/static/src/model/relational_model/utils.js b/addons/web/static/src/model/relational_model/utils.js index 95fd93964bf..46d5a58e5d6 100644 --- a/addons/web/static/src/model/relational_model/utils.js +++ b/addons/web/static/src/model/relational_model/utils.js @@ -3,7 +3,6 @@ import { markup, onWillDestroy, onWillStart, onWillUpdateProps, useComponent } from "@odoo/owl"; import { evalPartialContext, makeContext } from "@web/core/context"; import { deserializeDate, deserializeDateTime } from "@web/core/l10n/dates"; -import { Domain } from "@web/core/domain"; import { x2ManyCommands } from "@web/core/orm_service"; import { Deferred } from "@web/core/utils/concurrency"; import { omit } from "@web/core/utils/objects"; @@ -22,9 +21,9 @@ export function makeActiveField({ } = {}) { return { context: context || "{}", - invisible: invisible || false, - readonly: readonly || false, - required: required || false, + invisible: invisible || "False", + readonly: readonly || "False", + required: required || "False", onChange: onChange || false, forceSave: forceSave || false, isHandle: isHandle || false, @@ -74,7 +73,7 @@ export function completeActiveFields(activeFields, extraActiveFields) { for (const fieldName in extraActiveFields) { const extraActiveField = { ...extraActiveFields[fieldName], - invisible: true, + invisible: 'True', }; if (fieldName in activeFields) { completeActiveField(activeFields[fieldName], extraActiveField); @@ -103,29 +102,29 @@ export function createPropertyActiveField(property) { return activeField; } -function combineModifiers(mod1, mod2, operator) { +export function combineModifiers(mod1, mod2, operator) { if (operator === "AND") { - if (mod1 === false || mod2 === false) { - return false; + if (!mod1 || mod1 === "False" || !mod2 || mod2 === "False") { + return "False"; } - if (mod1 === true) { + if (mod1 === "True") { return mod2; } - if (mod2 === true) { + if (mod2 === "True") { return mod1; } - return Domain.and([mod1, mod2]).toString(); + return "(" + mod1 + ") and (" + mod2 + ")"; } else if (operator === "OR") { - if (mod1 === true || mod2 === true) { - return true; + if (mod1 === "True" || mod2 === "True") { + return "True"; } - if (mod1 === false) { + if (!mod1 || mod1 === "False") { return mod2; } - if (mod2 === false) { + if (!mod2 || mod2 === "False") { return mod1; } - return Domain.or([mod1, mod2]).toString(); + return "(" + mod1 + ") or (" + mod2 + ")"; } throw new Error( `Operator provided to "combineModifiers" must be "AND" or "OR", received ${operator}` @@ -166,12 +165,11 @@ export function extractFieldsFromArchInfo({ fieldNodes, widgetNodes }, fields) { const activeFields = {}; for (const fieldNode of Object.values(fieldNodes)) { const fieldName = fieldNode.name; - const modifiers = fieldNode.modifiers || {}; const activeField = makeActiveField({ context: fieldNode.context, - invisible: modifiers.invisible || modifiers.column_invisible, - readonly: modifiers.readonly, - required: modifiers.required, + invisible: combineModifiers(fieldNode.invisible, fieldNode.column_invisible, "OR"), + readonly: fieldNode.readonly, + required: fieldNode.required, onChange: fieldNode.onChange, forceSave: fieldNode.forceSave, isHandle: fieldNode.isHandle, @@ -205,7 +203,7 @@ export function extractFieldsFromArchInfo({ fieldNodes, widgetNodes }, fields) { } } if (fieldNode.field?.useSubView) { - activeField.required = false; + activeField.required = 'False'; } } @@ -272,7 +270,7 @@ export function getFieldsSpec( const { related, limit, defaultOrderBy, invisible } = activeFields[fieldName]; fieldsSpec[fieldName] = {}; // X2M - if (related && (invisible !== true || withInvisible)) { + if (related && ((invisible !== "True" && invisible !== "1") || withInvisible)) { fieldsSpec[fieldName].fields = getFieldsSpec( related.activeFields, related.fields, @@ -291,7 +289,7 @@ export function getFieldsSpec( // M2O if (fields[fieldName].type === "many2one") { fieldsSpec[fieldName].fields = {}; - if (invisible !== true) { + if (invisible !== "True" && invisible !== "1") { fieldsSpec[fieldName].fields.display_name = {}; } } diff --git a/addons/web/static/src/search/search_arch_parser.js b/addons/web/static/src/search/search_arch_parser.js index 56d64ec7190..3432bb9ecb8 100644 --- a/addons/web/static/src/search/search_arch_parser.js +++ b/addons/web/static/src/search/search_arch_parser.js @@ -2,7 +2,7 @@ import { makeContext } from "@web/core/context"; import { _t } from "@web/core/l10n/translation"; -import { evaluateExpr } from "@web/core/py_js/py"; +import { evaluateExpr, evaluateBooleanExpr } from "@web/core/py_js/py"; import { XMLParser } from "@web/core/utils/xml"; import { DEFAULT_INTERVAL, DEFAULT_PERIOD } from "@web/search/utils/dates"; @@ -109,9 +109,8 @@ export class SearchArchParser extends XMLParser { visitField(node) { this.pushGroup("field"); const preField = { type: "field" }; - const modifiers = JSON.parse(node.getAttribute("modifiers") || "{}"); - if (modifiers.invisible === true) { - preField.invisible = true; + if (node.getAttribute("invisible") === "True" || node.getAttribute("invisible") === "1") { + preField.invisible = "True"; } if (node.hasAttribute("domain")) { preField.domain = node.getAttribute("domain"); @@ -221,9 +220,8 @@ export class SearchArchParser extends XMLParser { preSearchItem.domain = stringRepr; } } - const modifiers = JSON.parse(node.getAttribute("modifiers") || "{}"); - if (modifiers.invisible === true) { - preSearchItem.invisible = true; + if (node.getAttribute("invisible") === "True" || node.getAttribute("invisible") === "1") { + preSearchItem.invisible = "True"; const fieldName = preSearchItem.fieldName; if (fieldName && !this.fields[fieldName]) { // In some case when a field is limited to specific groups @@ -290,8 +288,7 @@ export class SearchArchParser extends XMLParser { if (node.nodeType !== 1 || node.tagName !== "field") { continue; } - const modifiers = JSON.parse(node.getAttribute("modifiers") || "{}"); - if (modifiers.invisible === true) { + if (node.getAttribute("invisible") === "True" || node.getAttribute("invisible") === "1") { continue; } const attrs = {}; @@ -303,8 +300,8 @@ export class SearchArchParser extends XMLParser { const section = { color: attrs.color || null, description: attrs.string || this.fields[attrs.name].string, - enableCounters: Boolean(evaluateExpr(attrs.enable_counters || "0")), - expand: Boolean(evaluateExpr(attrs.expand || "0")), + enableCounters: evaluateBooleanExpr(attrs.enable_counters), + expand: evaluateBooleanExpr(attrs.expand), fieldName: attrs.name, icon: attrs.icon || null, id: nextSectionId++, @@ -315,7 +312,7 @@ export class SearchArchParser extends XMLParser { if (type === "category") { section.activeValueId = this.searchPanelDefaults[attrs.name]; section.icon = section.icon || "fa-folder"; - section.hierarchize = Boolean(evaluateExpr(attrs.hierarchize || "1")); + section.hierarchize = evaluateBooleanExpr(attrs.hierarchize || "1"); section.values.set(false, { childrenIds: [], display_name: ALL.toString(), diff --git a/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.js b/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.js index 1bb8e840374..e08f544f9e2 100644 --- a/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.js +++ b/addons/web/static/src/views/calendar/calendar_common/calendar_common_popover.js @@ -1,7 +1,7 @@ /** @odoo-module **/ import { Dialog } from "@web/core/dialog/dialog"; -import { evalDomain } from "@web/core/domain"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { is24HourFormat } from "@web/core/l10n/dates"; import { Field } from "@web/views/fields/field"; import { Record } from "@web/views/record"; @@ -33,7 +33,7 @@ export class CalendarCommonPopover extends Component { } isInvisible(fieldNode, record) { - return evalDomain(fieldNode.modifiers.invisible, record.evalContext); + return evaluateBooleanExpr(fieldNode.invisible, record.evalContext); } computeDateTimeAndDuration() { diff --git a/addons/web/static/src/views/debug_items.js b/addons/web/static/src/views/debug_items.js index 5c20864eae6..23c4510aec8 100644 --- a/addons/web/static/src/views/debug_items.js +++ b/addons/web/static/src/views/debug_items.js @@ -2,7 +2,7 @@ import { _t } from "@web/core/l10n/translation"; import { Dialog } from "@web/core/dialog/dialog"; -import { evalDomain } from "@web/core/domain"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { editModelDebug } from "@web/core/debug/debug_utils"; import { formatDateTime, deserializeDateTime } from "@web/core/l10n/dates"; import { registry } from "@web/core/registry"; @@ -69,6 +69,9 @@ export function editView({ accessRights, component, env }) { type = component.props.viewInfo.type; type = type === "tree" ? "list" : type; } + if (!type) { + return; + } const displayName = type[0].toUpperCase() + type.slice(1); const description = env._t("Edit View: ") + displayName; return { @@ -221,8 +224,8 @@ class SetDefaultDialog extends Component { // ignore fields which are empty, invisible, readonly, o2m or m2m if ( !value || - evalDomain(this.activeFields[fieldName].invisible, evalContext) || - evalDomain(this.activeFields[fieldName].readonly, evalContext) || + evaluateBooleanExpr(this.activeFields[fieldName].invisible, evalContext) || + evaluateBooleanExpr(this.activeFields[fieldName].readonly, evalContext) || fieldInfo.type === "one2many" || fieldInfo.type === "many2many" || fieldInfo.type === "binary" || diff --git a/addons/web/static/src/views/fields/badge/badge_field.js b/addons/web/static/src/views/fields/badge/badge_field.js index c352197bb92..73448efc5ca 100644 --- a/addons/web/static/src/views/fields/badge/badge_field.js +++ b/addons/web/static/src/views/fields/badge/badge_field.js @@ -1,7 +1,7 @@ /** @odoo-module **/ import { _t } from "@web/core/l10n/translation"; -import { evaluateExpr } from "@web/core/py_js/py"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { registry } from "@web/core/registry"; import { standardFieldProps } from "@web/views/fields/standard_field_props"; @@ -28,7 +28,7 @@ export class BadgeField extends Component { get classFromDecoration() { const evalContext = this.props.record.evalContext; for (const decorationName in this.props.decorations) { - if (evaluateExpr(this.props.decorations[decorationName], evalContext)) { + if (evaluateBooleanExpr(this.props.decorations[decorationName], evalContext)) { return `text-bg-${decorationName}`; } } diff --git a/addons/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js b/addons/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js index cce328fe8ff..d3edd759bdb 100644 --- a/addons/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js +++ b/addons/web/static/src/views/fields/copy_clipboard/copy_clipboard_field.js @@ -1,8 +1,7 @@ /** @odoo-module **/ -import { evalDomain } from "@web/core/domain"; import { _t } from "@web/core/l10n/translation"; -import { evaluateExpr } from "@web/core/py_js/py"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { registry } from "@web/core/registry"; import { omit } from "@web/core/utils/objects"; @@ -37,12 +36,7 @@ class CopyClipboardField extends Component { return this.props.record.fields[this.props.name].type; } get disabled() { - const context = this.props.record.evalContext; - const evaluated = this.props.disabledExpr ? evaluateExpr(this.props.disabledExpr) : false; - if (evaluated instanceof Array) { - return evalDomain(evaluated, context); - } - return Boolean(evaluated); + return this.props.disabledExpr ? evaluateBooleanExpr(this.props.disabledExpr, this.props.record.evalContext) : false; } } diff --git a/addons/web/static/src/views/fields/datetime/datetime_field.js b/addons/web/static/src/views/fields/datetime/datetime_field.js index 56eaaadc2f1..dfd1bd5e693 100644 --- a/addons/web/static/src/views/fields/datetime/datetime_field.js +++ b/addons/web/static/src/views/fields/datetime/datetime_field.js @@ -24,7 +24,7 @@ import { standardFieldProps } from "../standard_field_props"; * maxDate?: string; * minDate?: string; * placeholder?: string; - * required?: boolean; + * required?: string | boolean; * rounding?: number; * startDateField?: string; * warnFuture?: boolean; @@ -41,7 +41,7 @@ export class DateTimeField extends Component { maxDate: { type: String, optional: true }, minDate: { type: String, optional: true }, placeholder: { type: String, optional: true }, - required: { type: Boolean, optional: true }, + required: { type: [Boolean, String], optional: true }, rounding: { type: Number, optional: true }, startDateField: { type: String, optional: true }, warnFuture: { type: Boolean, optional: true }, @@ -267,27 +267,27 @@ export const dateField = { }, ], supportedTypes: ["date"], - extractProps: ({ attrs, modifiers, options }) => ({ + extractProps: ({ attrs, options }, dynamicInfo) => ({ endDateField: options[END_DATE_FIELD_OPTION], maxDate: options.max_date, minDate: options.min_date, placeholder: attrs.placeholder, - required: Boolean(modifiers.required), + required: 'required' in attrs ? attrs.required : dynamicInfo.required, rounding: options.rounding && parseInt(options.rounding, 10), startDateField: options[START_DATE_FIELD_OPTION], warnFuture: archParseBoolean(options.warn_future), }), - fieldDependencies: ({ type, modifiers, options }) => { + fieldDependencies: ({ type, attrs, options }) => { const deps = []; if (options[START_DATE_FIELD_OPTION]) { - deps.push({ name: options[START_DATE_FIELD_OPTION], type, modifiers }); + deps.push({ name: options[START_DATE_FIELD_OPTION], type, attrs }); if (options[END_DATE_FIELD_OPTION]) { console.warn( `A field cannot have both ${START_DATE_FIELD_OPTION} and ${END_DATE_FIELD_OPTION} options at the same time` ); } } else if (options[END_DATE_FIELD_OPTION]) { - deps.push({ name: options[END_DATE_FIELD_OPTION], type, modifiers }); + deps.push({ name: options[END_DATE_FIELD_OPTION], type, attrs }); } return deps; }, diff --git a/addons/web/static/src/views/fields/field.js b/addons/web/static/src/views/fields/field.js index b48aac5020d..a906921530f 100644 --- a/addons/web/static/src/views/fields/field.js +++ b/addons/web/static/src/views/fields/field.js @@ -1,11 +1,15 @@ /** @odoo-module **/ -import { Domain, evalDomain } from "@web/core/domain"; -import { evaluateExpr } from "@web/core/py_js/py"; +import { Domain } from "@web/core/domain"; +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"; @@ -39,8 +43,8 @@ export function getFieldFromRegistry(fieldType, widget, viewType, jsClass) { } export function fieldVisualFeedback(field, record, fieldName, fieldInfo) { - const modifiers = fieldInfo.modifiers || {}; - const readonly = evalDomain(modifiers.readonly, record.evalContext); + const readonly = evaluateBooleanExpr(fieldInfo.readonly, record.evalContext); + const required = evaluateBooleanExpr(fieldInfo.required, record.evalContext); const inEdit = record.isInEdition; let empty = !record.isNew; @@ -52,7 +56,7 @@ export function fieldVisualFeedback(field, record, fieldName, fieldInfo) { empty = inEdit ? empty && readonly : empty; return { readonly, - required: evalDomain(modifiers.required, record.evalContext), + required, invalid: record.isFieldInvalid(fieldName), empty, }; @@ -67,7 +71,10 @@ export function getPropertyFieldInfo(propertyField) { type, widget: type, options: {}, - modifiers: {}, + column_invisible: "False", + invisible: "False", + readonly: "False", + required: "False", attrs: {}, relatedPropertyField, @@ -76,7 +83,6 @@ export function getPropertyFieldInfo(propertyField) { help: undefined, onChange: false, forceSave: false, - alwaysInvisible: false, decorations: {}, // ??? }; @@ -150,7 +156,7 @@ export class Field extends Component { const { decorations } = fieldInfo; const evalContext = record.evalContext; for (const decoName in decorations) { - const value = evaluateExpr(decorations[decoName], evalContext); + const value = evaluateBooleanExpr(decorations[decoName], evalContext); classNames[getClassNameFromDecoration(decoName)] = value; } } @@ -164,16 +170,13 @@ export class Field extends Component { get fieldComponentProps() { const record = this.props.record; - const evalContext = record.evalContext; - const readonly = this.props.readonly === true; + let readonly = this.props.readonly || false; - let readonlyFromModifiers = false; let propsFromNode = {}; if (this.props.fieldInfo) { + const evalContext = record.getEvalContext?.(false) || record.evalContext; let fieldInfo = this.props.fieldInfo; - - const modifiers = fieldInfo.modifiers || {}; - readonlyFromModifiers = evalDomain(modifiers.readonly, evalContext); + readonly = readonly || evaluateBooleanExpr(fieldInfo.readonly, evalContext); if (this.field.extractProps) { if (this.props.attrs) { @@ -182,14 +185,13 @@ export class Field extends Component { attrs: { ...fieldInfo.attrs, ...this.props.attrs }, }; } + const dynamicInfo = { get context() { return getFieldContext(record, fieldInfo.name, fieldInfo.context); }, domain() { - const evalContext = record.getEvalContext - ? record.getEvalContext(true) - : record.evalContext; + const evalContext = record.getEvalContext?.(true) || record.evalContext; if (fieldInfo.domain) { return new Domain(evaluateExpr(fieldInfo.domain, evalContext)).toList(); } @@ -198,14 +200,8 @@ export class Field extends Component { ? new Domain(evaluateExpr(domain, evalContext)).toList() : domain || []; }, - readonly: readonly || readonlyFromModifiers, - get required() { - return ( - fieldInfo.modifiers && - fieldInfo.modifiers.required && - evalDomain(fieldInfo.modifiers.required, record.evalContext) - ); - }, + required: evaluateBooleanExpr(fieldInfo.required, evalContext), + readonly: readonly, }; propsFromNode = this.field.extractProps(fieldInfo, dynamicInfo); } @@ -221,7 +217,7 @@ export class Field extends Component { delete props.readonly; return { - readonly: readonly || !record.isInEdition || readonlyFromModifiers || false, + readonly: readonly || !record.isInEdition || false, ...propsFromNode, ...props, }; @@ -246,13 +242,15 @@ Field.parseFieldNode = function (node, models, modelName, viewType, jsClass) { const name = node.getAttribute("name"); const widget = node.getAttribute("widget"); const fields = models[modelName]; + if (!fields[name]) { + throw new Error(`"${modelName}"."${name}" field is undefined.`); + } const field = getFieldFromRegistry(fields[name].type, widget, viewType, jsClass); const fieldInfo = { name, type: fields[name].type, viewType, widget, - modifiers: {}, field, context: "{}", string: fields[name].string, @@ -260,12 +258,22 @@ Field.parseFieldNode = function (node, models, modelName, viewType, jsClass) { onChange: false, forceSave: false, options: {}, - alwaysInvisible: false, decorations: {}, attrs: {}, domain: undefined, }; + for (const attr of ["invisible", "column_invisible", "readonly", "required"]) { + fieldInfo[attr] = node.getAttribute(attr); + if (fieldInfo[attr] === "True") { + if (attr === "column_invisible") { + fieldInfo.invisible = "True"; + } + } else if (fieldInfo[attr] === null && fields[name][attr]) { + fieldInfo[attr] = "True"; + } + } + for (const { name, value } of node.attributes) { if (["name", "widget"].includes(name)) { // avoid adding name and widget to attrs @@ -273,11 +281,6 @@ Field.parseFieldNode = function (node, models, modelName, viewType, jsClass) { } if (["context", "string", "help", "domain"].includes(name)) { fieldInfo[name] = value; - } else if (name === "modifiers") { - fieldInfo.modifiers = JSON.parse(value); - fieldInfo.alwaysInvisible = - fieldInfo.modifiers.invisible === true || - fieldInfo.modifiers.column_invisible === true; } else if (name === "on_change") { fieldInfo.onChange = archParseBoolean(value); } else if (name === "options") { @@ -293,7 +296,7 @@ Field.parseFieldNode = function (node, models, modelName, viewType, jsClass) { } } if (name === "id") { - fieldInfo.modifiers.readonly = true; + fieldInfo.readonly = 'True'; } if (widget === "handle") { diff --git a/addons/web/static/src/views/fields/field_tooltip.js b/addons/web/static/src/views/fields/field_tooltip.js index c19bb817f55..7c673a24f2d 100644 --- a/addons/web/static/src/views/fields/field_tooltip.js +++ b/addons/web/static/src/views/fields/field_tooltip.js @@ -18,7 +18,10 @@ export function getTooltipInfo(params) { widgetDescription, context: params.fieldInfo.context, domain: params.field.domain, - modifiers: JSON.stringify(params.fieldInfo.modifiers), + invisible: params.fieldInfo.invisible, + column_invisible: params.fieldInfo.column_invisible, + readonly: params.fieldInfo.readonly, + required: params.fieldInfo.required, changeDefault: params.field.change_default, relation: params.field.relation, selection: params.field.selection, diff --git a/addons/web/static/src/views/fields/field_tooltip.xml b/addons/web/static/src/views/fields/field_tooltip.xml index 022bfb8edce..f6b9f60bc63 100644 --- a/addons/web/static/src/views/fields/field_tooltip.xml +++ b/addons/web/static/src/views/fields/field_tooltip.xml @@ -40,9 +40,21 @@ Domain: -
  • - Modifiers: - +
  • + Invisible: + +
  • +
  • + Column invisible: + +
  • +
  • + Required: + +
  • +
  • + Readonly: +
  • Default: diff --git a/addons/web/static/src/views/fields/many2one/many2one_field.js b/addons/web/static/src/views/fields/many2one/many2one_field.js index 4cca2b0b515..0944f1b9976 100644 --- a/addons/web/static/src/views/fields/many2one/many2one_field.js +++ b/addons/web/static/src/views/fields/many2one/many2one_field.js @@ -5,7 +5,7 @@ import { isMobileOS } from "@web/core/browser/feature_detection"; import { makeContext } from "@web/core/context"; import { Dialog } from "@web/core/dialog/dialog"; import { _t } from "@web/core/l10n/translation"; -import { evaluateExpr } from "@web/core/py_js/py"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { registry } from "@web/core/registry"; import { useChildRef, useOwnedDialogs, useService } from "@web/core/utils/hooks"; import { Many2XAutocomplete, useOpenMany2XRecord } from "@web/views/fields/relational_utils"; @@ -168,7 +168,7 @@ export class Many2OneField extends Component { get classFromDecoration() { const evalContext = this.props.record.evalContext; for (const decorationName in this.props.decorations) { - if (evaluateExpr(this.props.decorations[decorationName], evalContext)) { + if (evaluateBooleanExpr(this.props.decorations[decorationName], evalContext)) { return `text-${decorationName}`; } } @@ -217,8 +217,11 @@ export class Many2OneField extends Component { }; } getDomain() { - const domain = this.props.domain; - return typeof domain === "function" ? domain() : domain; + let domain = this.props.domain; + if (typeof domain === "function") { + domain = domain(); + } + return domain; } async openAction() { const action = await this.orm.call(this.relation, "get_formview_action", [[this.resId]], { diff --git a/addons/web/static/src/views/fields/relational_utils.js b/addons/web/static/src/views/fields/relational_utils.js index 664e1986050..8585cff8e69 100644 --- a/addons/web/static/src/views/fields/relational_utils.js +++ b/addons/web/static/src/views/fields/relational_utils.js @@ -3,7 +3,7 @@ import { AutoComplete } from "@web/core/autocomplete/autocomplete"; import { makeContext } from "@web/core/context"; import { Dialog } from "@web/core/dialog/dialog"; -import { evalDomain } from "@web/core/domain"; +import { Domain } from "@web/core/domain"; import { RPCError } from "@web/core/network/rpc_service"; import { Cache } from "@web/core/utils/cache"; import { @@ -121,7 +121,7 @@ export function useActiveActions({ let evalFn = () => actionName !== "write"; if (!isNull(crudOptions[actionName])) { const action = crudOptions[actionName]; - evalFn = (evalContext) => evalDomain(action, evalContext); + evalFn = (evalContext) => Boolean(action && new Domain(action).contains(evalContext)); } if (actionName in subViewActiveActions) { diff --git a/addons/web/static/src/views/fields/x2many/x2many_field.js b/addons/web/static/src/views/fields/x2many/x2many_field.js index 294129ed001..bb6e702b496 100644 --- a/addons/web/static/src/views/fields/x2many/x2many_field.js +++ b/addons/web/static/src/views/fields/x2many/x2many_field.js @@ -1,7 +1,7 @@ /** @odoo-module **/ import { makeContext } from "@web/core/context"; -import { evalDomain } from "@web/core/domain"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { _t } from "@web/core/l10n/translation"; import { Pager } from "@web/core/pager/pager"; import { registry } from "@web/core/registry"; @@ -191,6 +191,9 @@ export class X2ManyField extends Component { archInfo, list: this.list, openRecord: this.openRecord.bind(this), + evalViewModifier: (modifier) => { + return evaluateBooleanExpr(modifier, this.list.evalContext); + }, }; if (this.props.viewMode === "kanban") { @@ -207,32 +210,9 @@ export class X2ManyField extends Component { return props; } - // handle column_invisible modifiers - const columns = archInfo.columns - .map((col) => { - // first remove (column_)invisible buttons in button_groups - if (col.type === "button_group") { - const buttons = col.buttons.filter((button) => { - return !this.evalColumnInvisibleModifier(button.modifiers); - }); - return { ...col, buttons }; - } - return col; - }) - .filter((col) => { - // filter out (column_)invisible fields and empty button_groups - if (col.type === "field") { - return !this.evalColumnInvisibleModifier(col.modifiers); - } else if (col.type === "button_group") { - return col.buttons.length > 0; - } - return true; - }); - const editable = (this.archInfo.activeActions.edit && archInfo.editable) || this.props.editable; props.activeActions = this.activeActions; - props.archInfo = { ...archInfo, columns }; props.cycleOnTab = false; props.editable = !this.props.readonly && editable; props.nestedKeyOptionalFieldsData = this.nestedKeyOptionalFieldsData; @@ -244,13 +224,6 @@ export class X2ManyField extends Component { return props; } - evalColumnInvisibleModifier(modifiers) { - if ("column_invisible" in modifiers) { - return evalDomain(modifiers.column_invisible, this.list.evalContext); - } - return false; - } - async onAdd({ context, editable } = {}) { const domain = typeof this.props.domain === "function" ? this.props.domain() : this.props.domain; diff --git a/addons/web/static/src/views/form/form_compiler.js b/addons/web/static/src/views/form/form_compiler.js index 1fbadf8527b..7156e3324c4 100644 --- a/addons/web/static/src/views/form/form_compiler.js +++ b/addons/web/static/src/views/form/form_compiler.js @@ -134,17 +134,19 @@ export class FormCompiler extends ViewCompiler { let hasContent = false; for (const child of el.children) { const invisible = getModifier(child, "invisible"); - if (this.isAlwaysInvisible(invisible, params)) { + if (!params.compileInvisibleNodes && (invisible === "True" || invisible === "1")) { continue; } hasContent = true; let isVisibleExpr; - if (typeof invisible === "boolean") { - isVisibleExpr = `${invisible ? false : true}`; + if (!invisible || invisible === "False" || invisible === "0") { + isVisibleExpr = "true"; + } else if (invisible === "True" || invisible === "1") { + isVisibleExpr = "false"; } else { - isVisibleExpr = `!__comp__.evalDomainFromRecord(__comp__.props.record,${JSON.stringify( + isVisibleExpr = `!__comp__.evaluateBooleanExpr(${JSON.stringify( invisible - )})`; + )},__comp__.props.record.evalContext)`; } const mainSlot = createElement("t", { "t-set-slot": `slot_${slotId++}`, @@ -277,7 +279,7 @@ export class FormCompiler extends ViewCompiler { } const invisible = getModifier(child, "invisible"); - if (this.isAlwaysInvisible(invisible, params)) { + if (!params.compileInvisibleNodes && (invisible === "True" || invisible === "1")) { continue; } @@ -340,12 +342,14 @@ export class FormCompiler extends ViewCompiler { if (slotContent && !isTextNode(slotContent)) { let isVisibleExpr; - if (typeof invisible === "boolean") { - isVisibleExpr = `${invisible ? false : true}`; + if (!invisible || invisible === "False" || invisible === "0") { + isVisibleExpr = "true"; + } else if (invisible === "True" || invisible === "1") { + isVisibleExpr = "false"; } else { - isVisibleExpr = `!__comp__.evalDomainFromRecord(__comp__.props.record,${JSON.stringify( + isVisibleExpr = `!__comp__.evaluateBooleanExpr(${JSON.stringify( invisible - )})`; + )},__comp__.props.record.evalContext)`; } mainSlot.setAttribute("isVisible", isVisibleExpr); if (itemSpan > 0) { @@ -499,7 +503,7 @@ export class FormCompiler extends ViewCompiler { continue; } const invisible = getModifier(child, "invisible"); - if (this.isAlwaysInvisible(invisible, params)) { + if (!params.compileInvisibleNodes && (invisible === "True" || invisible === "1")) { continue; } @@ -537,15 +541,17 @@ export class FormCompiler extends ViewCompiler { }; } - let isVisible; - if (typeof invisible === "boolean") { - isVisible = `${!invisible}`; + let isVisibleExpr; + if (!invisible || invisible === "False" || invisible === "0") { + isVisibleExpr = "true"; + } else if (invisible === "True" || invisible === "1") { + isVisibleExpr = "false"; } else { - isVisible = `!__comp__.evalDomainFromRecord(__comp__.props.record,${JSON.stringify( + isVisibleExpr = `!__comp__.evaluateBooleanExpr(${JSON.stringify( invisible - )})`; + )},__comp__.props.record.evalContext)`; } - pageSlot.setAttribute("isVisible", isVisible); + pageSlot.setAttribute("isVisible", isVisibleExpr); for (const contents of child.children) { append(pageSlot, this.compileNode(contents, { ...params, currentSlot: pageSlot })); diff --git a/addons/web/static/src/views/form/form_controller.js b/addons/web/static/src/views/form/form_controller.js index a193b04238f..f6d3dad8f9f 100644 --- a/addons/web/static/src/views/form/form_controller.js +++ b/addons/web/static/src/views/form/form_controller.js @@ -3,13 +3,13 @@ import { hasTouch } from "@web/core/browser/feature_detection"; import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { makeContext } from "@web/core/context"; -import { evalDomain } from "@web/core/domain"; import { useDebugCategory } from "@web/core/debug/debug_context"; import { registry } from "@web/core/registry"; import { SIZES } from "@web/core/ui/ui_service"; import { useBus, useService } from "@web/core/utils/hooks"; import { omit } from "@web/core/utils/objects"; import { createElement } from "@web/core/utils/xml"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { Layout } from "@web/search/layout"; import { usePager } from "@web/search/pager_hook"; import { standardViewProps } from "@web/views/standard_view_props"; @@ -48,7 +48,7 @@ export async function loadSubViews( if (!isX2Many(field)) { continue; // what follows only concerns x2many fields } - if (fieldInfo.alwaysInvisible) { + if (fieldInfo.invisible === "True" || fieldInfo.invisible === "1") { continue; // no need to fetch the sub view if the field is always invisible } if (!fieldInfo.field.useSubView) { @@ -110,6 +110,7 @@ export async function loadSubViews( export class FormController extends Component { setup() { + this.evaluateBooleanExpr = evaluateBooleanExpr; this.dialogService = useService("dialog"); this.router = useService("router"); this.user = useService("user"); @@ -559,10 +560,6 @@ export class FormController extends Component { result["o_field_highlight"] = size < SIZES.SM || hasTouch(); return result; } - - evalDomainFromRecord(record, expr) { - return evalDomain(expr, record.evalContext); - } } FormController.template = `web.FormView`; diff --git a/addons/web/static/src/views/form/form_renderer.js b/addons/web/static/src/views/form/form_renderer.js index e798788aa30..5df0a8e0e7c 100644 --- a/addons/web/static/src/views/form/form_renderer.js +++ b/addons/web/static/src/views/form/form_renderer.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import { evalDomain } from "@web/core/domain"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { Notebook } from "@web/core/notebook/notebook"; import { Setting } from "./setting/setting"; import { Field } from "@web/views/fields/field"; @@ -30,6 +30,7 @@ import { export class FormRenderer extends Component { setup() { + this.evaluateBooleanExpr = evaluateBooleanExpr; const { archInfo, Compiler, record } = this.props; const { arch, xmlDoc } = archInfo; const templates = { FormRenderer: xmlDoc }; @@ -79,10 +80,6 @@ export class FormRenderer extends Component { get shouldAutoFocus() { return !this.props.archInfo.disableAutofocus; } - - evalDomainFromRecord(record, expr) { - return evalDomain(expr, record.evalContext); - } } FormRenderer.template = xml``; diff --git a/addons/web/static/src/views/form/setting/setting.js b/addons/web/static/src/views/form/setting/setting.js index eceb1aeb72f..ecb40d5b15b 100644 --- a/addons/web/static/src/views/form/setting/setting.js +++ b/addons/web/static/src/views/form/setting/setting.js @@ -9,10 +9,7 @@ export class Setting extends Component { setup() { if (this.props.fieldName) { this.fieldType = this.props.record.fields[this.props.fieldName].type; - if ( - typeof this.props.fieldInfo.modifiers.readonly === "boolean" && - this.props.fieldInfo.modifiers.readonly === true - ) { + if (this.props.fieldInfo.readonly === "True") { this.notMuttedLabel = true; } } diff --git a/addons/web/static/src/views/graph/graph_arch_parser.js b/addons/web/static/src/views/graph/graph_arch_parser.js index 6db7b3bd72b..9d2ad6c4154 100644 --- a/addons/web/static/src/views/graph/graph_arch_parser.js +++ b/addons/web/static/src/views/graph/graph_arch_parser.js @@ -55,8 +55,7 @@ export class GraphArchParser extends XMLParser { } archInfo.fieldAttrs[fieldName].string = string; } - const modifiers = JSON.parse(node.getAttribute("modifiers") || "{}"); - if (modifiers.invisible === true) { + if (node.getAttribute("invisible") === "True" || node.getAttribute("invisible") === "1") { if (!archInfo.fieldAttrs[fieldName]) { archInfo.fieldAttrs[fieldName] = {}; } diff --git a/addons/web/static/src/views/kanban/kanban_arch_parser.js b/addons/web/static/src/views/kanban/kanban_arch_parser.js index a54cdba1cf5..50a97ad94e1 100644 --- a/addons/web/static/src/views/kanban/kanban_arch_parser.js +++ b/addons/web/static/src/views/kanban/kanban_arch_parser.js @@ -73,7 +73,7 @@ export class KanbanArchParser extends XMLParser { type: "button", id: button_id++, })) - .filter((button) => button.modifiers.invisible !== true); + .filter((button) => button.invisible !== "True" && button.invisible !== "1"); return false; } else if (node.tagName === "control") { for (const childNode of node.children) { diff --git a/addons/web/static/src/views/kanban/kanban_controller.js b/addons/web/static/src/views/kanban/kanban_controller.js index 8f810076196..1b764cd4046 100644 --- a/addons/web/static/src/views/kanban/kanban_controller.js +++ b/addons/web/static/src/views/kanban/kanban_controller.js @@ -4,6 +4,7 @@ import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_d import { _t } from "@web/core/l10n/translation"; import { useService } from "@web/core/utils/hooks"; import { CogMenu } from "@web/search/cog_menu/cog_menu"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { Layout } from "@web/search/layout"; import { usePager } from "@web/search/pager_hook"; import { SearchBar } from "@web/search/search_bar/search_bar"; @@ -206,6 +207,10 @@ export class KanbanController extends Component { }); } + evalViewModifier(modifier) { + return evaluateBooleanExpr(modifier, {context: this.props.context}); + }; + async openRecord(record, mode) { const activeIds = this.model.root.records.map((datapoint) => datapoint.resId); this.props.selectRecord(record.resId, { activeIds, mode }); diff --git a/addons/web/static/src/views/kanban/kanban_controller.xml b/addons/web/static/src/views/kanban/kanban_controller.xml index 8b2cf3752b0..3e1e99b23cb 100644 --- a/addons/web/static/src/views/kanban/kanban_controller.xml +++ b/addons/web/static/src/views/kanban/kanban_controller.xml @@ -51,6 +51,7 @@ canQuickCreate="canQuickCreate" quickCreateState="quickCreateState" progressBarState="progressBarState" + evalViewModifier.bind="evalViewModifier" /> diff --git a/addons/web/static/src/views/kanban/kanban_record.js b/addons/web/static/src/views/kanban/kanban_record.js index 1e2fd7f3913..56a31705484 100644 --- a/addons/web/static/src/views/kanban/kanban_record.js +++ b/addons/web/static/src/views/kanban/kanban_record.js @@ -1,7 +1,7 @@ /** @odoo-module **/ import { ColorList } from "@web/core/colorlist/colorlist"; -import { evalDomain } from "@web/core/domain"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { Dropdown } from "@web/core/dropdown/dropdown"; import { DropdownItem } from "@web/core/dropdown/dropdown_item"; import { registry } from "@web/core/registry"; @@ -179,6 +179,7 @@ export function isHtmlEmpty(innerHTML = "") { export class KanbanRecord extends Component { setup() { + this.evaluateBooleanExpr = evaluateBooleanExpr; this.action = useService("action"); this.dialog = useService("dialog"); this.notification = useService("notification"); @@ -243,10 +244,6 @@ export class KanbanRecord extends Component { }; } - evalDomainFromRecord(record, expr) { - return evalDomain(expr, record.evalContext); - } - getRecordClasses() { const { archInfo, canResequence, forceGlobalClick, record, progressBarState } = this.props; const classes = ["o_kanban_record d-flex"]; diff --git a/addons/web/static/src/views/kanban/kanban_record_quick_create.js b/addons/web/static/src/views/kanban/kanban_record_quick_create.js index f4752c651d2..43856b040ef 100644 --- a/addons/web/static/src/views/kanban/kanban_record_quick_create.js +++ b/addons/web/static/src/views/kanban/kanban_record_quick_create.js @@ -21,7 +21,7 @@ const DEFAULT_QUICK_CREATE_VIEW = { // note: the required modifier is written in the format returned by the server arch: /* xml */ `
    - + `, }; const DEFAULT_QUICK_CREATE_FIELDS = { diff --git a/addons/web/static/src/views/kanban/kanban_renderer.js b/addons/web/static/src/views/kanban/kanban_renderer.js index ba3df1a0622..926d96c743e 100644 --- a/addons/web/static/src/views/kanban/kanban_renderer.js +++ b/addons/web/static/src/views/kanban/kanban_renderer.js @@ -51,6 +51,7 @@ export class KanbanRenderer extends Component { "deleteRecord", "openRecord", "readonly", + "evalViewModifier", "forceGlobalClick?", "noContentHelp?", "scrollTop?", @@ -221,14 +222,13 @@ export class KanbanRenderer extends Component { const fieldNodes = Object.values(this.props.archInfo.fieldNodes).filter( (fieldNode) => fieldNode.name === groupByField.name ); - let isReadonly; + let isReadonly = this.props.list.fields[groupByField.name].readonly; if ( + !isReadonly && fieldNodes.length && - fieldNodes.some((fieldNode) => "readonly" in fieldNode.modifiers) + fieldNodes.some((fieldNode) => "readonly" in fieldNode) ) { - isReadonly = fieldNodes.every((fieldNode) => fieldNode.modifiers.readonly === true); - } else { - isReadonly = this.props.list.fields[groupByField.name].readonly; + isReadonly = fieldNodes.every((fieldNode) => fieldNode.readonly === 'True'); } return !isReadonly && this.isMovableField(groupByField); } diff --git a/addons/web/static/src/views/list/list_arch_parser.js b/addons/web/static/src/views/list/list_arch_parser.js index 00f55b15c05..f4b3671c0f2 100644 --- a/addons/web/static/src/views/list/list_arch_parser.js +++ b/addons/web/static/src/views/list/list_arch_parser.js @@ -1,10 +1,11 @@ /** @odoo-module */ +import { Field } from "@web/views/fields/field"; import { XMLParser } from "@web/core/utils/xml"; import { stringToOrderBy } from "@web/search/utils/order_by"; -import { Field } from "@web/views/fields/field"; import { archParseBoolean, getActiveActions, getDecoration, processButton } from "@web/views/utils"; import { encodeObjectForTemplate } from "@web/views/view_compiler"; +import { combineModifiers } from "@web/model/relational_model/utils"; import { Widget } from "@web/views/widgets/widget"; export class GroupListArchParser extends XMLParser { @@ -36,10 +37,6 @@ export class GroupListArchParser extends XMLParser { } export class ListArchParser extends XMLParser { - isColumnVisible(columnInvisibleModifier) { - return columnInvisibleModifier !== true; - } - parseFieldNode(node, models, modelName) { return Field.parseFieldNode(node, models, modelName, "list"); } @@ -77,25 +74,24 @@ export class ListArchParser extends XMLParser { buttonGroup = undefined; } if (node.tagName === "button") { - const modifiers = JSON.parse(node.getAttribute("modifiers") || "{}"); - if (this.isColumnVisible(modifiers.column_invisible)) { - const button = { - ...this.processButton(node), - defaultRank: "btn-link", - type: "button", - id: buttonId++, + const button = { + ...this.processButton(node), + defaultRank: "btn-link", + type: "button", + id: buttonId++, + }; + if (buttonGroup) { + buttonGroup.buttons.push(button); + buttonGroup.column_invisible = combineModifiers(buttonGroup.column_invisible, node.getAttribute('column_invisible'), "AND"); + } else { + buttonGroup = { + id: `column_${nextId++}`, + type: "button_group", + buttons: [button], + hasLabel: false, + column_invisible: node.getAttribute('column_invisible'), }; - if (buttonGroup) { - buttonGroup.buttons.push(button); - } else { - buttonGroup = { - id: `column_${nextId++}`, - type: "button_group", - buttons: [button], - hasLabel: false, - }; - columns.push(buttonGroup); - } + columns.push(buttonGroup); } } else if (node.tagName === "field") { const fieldInfo = this.parseFieldNode(node, models, modelName); @@ -108,20 +104,18 @@ export class ListArchParser extends XMLParser { if (fieldInfo.isHandle) { handleField = fieldInfo.name; } - if (this.isColumnVisible(fieldInfo.modifiers.column_invisible)) { - const label = fieldInfo.field.label; - columns.push({ - ...fieldInfo, - id: `column_${nextId++}`, - className: node.getAttribute("class"), // for oe_edit_only and oe_read_only - optional: node.getAttribute("optional") || false, - type: "field", - hasLabel: !( - archParseBoolean(fieldInfo.attrs.nolabel) || fieldInfo.field.noLabel - ), - label: (fieldInfo.widget && label && label.toString()) || fieldInfo.string, - }); - } + const label = fieldInfo.field.label; + columns.push({ + ...fieldInfo, + id: `column_${nextId++}`, + className: node.getAttribute("class"), // for oe_edit_only and oe_read_only + optional: node.getAttribute("optional") || false, + type: "field", + hasLabel: !( + archParseBoolean(fieldInfo.attrs.nolabel) || fieldInfo.field.noLabel + ), + label: (fieldInfo.widget && label && label.toString()) || fieldInfo.string, + }); return false; } else if (node.tagName === "widget") { const widgetInfo = this.parseWidgetNode(node); @@ -155,7 +149,7 @@ export class ListArchParser extends XMLParser { }; return false; } else if (node.tagName === "header") { - // AAB: not sure we need to handle invisible="1" button as the usecase seems way + // AAB: not sure we need to handle invisible="True" button as the usecase seems way // less relevant than for fields (so for buttons, relying on the modifiers logic // that applies later on could be enough, even if the value is always true) headerButtons = [...node.children] @@ -163,8 +157,7 @@ export class ListArchParser extends XMLParser { ...this.processButton(node), type: "button", id: buttonId++, - })) - .filter((button) => button.modifiers.invisible !== true); + })); return false; } else if (node.tagName === "control") { for (const childNode of node.children) { diff --git a/addons/web/static/src/views/list/list_controller.js b/addons/web/static/src/views/list/list_controller.js index cbf8a7aa19c..e05de8123fc 100644 --- a/addons/web/static/src/views/list/list_controller.js +++ b/addons/web/static/src/views/list/list_controller.js @@ -2,7 +2,7 @@ import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { download } from "@web/core/network/download"; -import { evaluateExpr } from "@web/core/py_js/py"; +import { evaluateExpr, evaluateBooleanExpr } from "@web/core/py_js/py"; import { unique } from "@web/core/utils/arrays"; import { useService, useBus } from "@web/core/utils/hooks"; import { omit } from "@web/core/utils/objects"; @@ -380,6 +380,10 @@ export class ListController extends Component { this.model.root.selectDomain(false); } + evalViewModifier(modifier) { + return evaluateBooleanExpr(modifier, this.model.root.evalContext); + } + get className() { return this.props.className; } diff --git a/addons/web/static/src/views/list/list_controller.xml b/addons/web/static/src/views/list/list_controller.xml index 4dc5ad4d1d5..68c8e9cc600 100644 --- a/addons/web/static/src/views/list/list_controller.xml +++ b/addons/web/static/src/views/list/list_controller.xml @@ -44,7 +44,7 @@
    - +
    - + diff --git a/addons/web/static/src/views/list/list_renderer.js b/addons/web/static/src/views/list/list_renderer.js index de29dd342a2..30f1ed8cd26 100644 --- a/addons/web/static/src/views/list/list_renderer.js +++ b/addons/web/static/src/views/list/list_renderer.js @@ -2,12 +2,11 @@ import { browser } from "@web/core/browser/browser"; import { CheckBox } from "@web/core/checkbox/checkbox"; -import { Domain, evalDomain } from "@web/core/domain"; import { Dropdown } from "@web/core/dropdown/dropdown"; import { DropdownItem } from "@web/core/dropdown/dropdown_item"; import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service"; import { Pager } from "@web/core/pager/pager"; -import { evaluateExpr } from "@web/core/py_js/py"; +import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { registry } from "@web/core/registry"; import { useBus, useService } from "@web/core/utils/hooks"; import { useSortable } from "@web/core/utils/sortable"; @@ -15,6 +14,7 @@ import { getTabableElements } from "@web/core/utils/ui"; import { Field, getPropertyFieldInfo } from "@web/views/fields/field"; import { getTooltipInfo } from "@web/views/fields/field_tooltip"; import { getClassNameFromDecoration } from "@web/views/utils"; +import { combineModifiers } from "@web/model/relational_model/utils"; import { ViewButton } from "@web/views/view_button/view_button"; import { useBounceButton } from "@web/views/view_hook"; import { Widget } from "@web/views/widgets/widget"; @@ -228,7 +228,13 @@ export class ListRenderer extends Component { if (list.isGrouped && col.widget === "handle") { return false; // no handle column if the list is grouped } - return !col.optional || this.optionalActiveFields[col.name]; + if (col.optional && !this.optionalActiveFields[col.name]) { + return false; + } + if (this.evalColumnInvisible(col.column_invisible)) { + return false; + } + return true; }); } @@ -263,6 +269,7 @@ export class ListRenderer extends Component { return { ...getPropertyFieldInfo(propertyField), id: `${column.id}_${propertyField.name}`, + column_invisible: combineModifiers(propertyField.column_invisible, column.column_invisible, 'OR'), classNames: column.classNames, optional: "hide", type: "field", @@ -274,12 +281,9 @@ export class ListRenderer extends Component { } getFieldProps(record, column) { - if (this.isCellReadonly(column, record) || this.isRecordReadonly(record)) { - return { - readonly: true, - }; - } - return {}; + return { + readonly: this.isCellReadonly(column, record) || this.isRecordReadonly(record), + }; } // The following code manipulates the DOM directly to avoid having to wait for a @@ -565,7 +569,9 @@ export class ListRenderer extends Component { get optionalFieldGroups() { const propertyGroups = {}; const optionalFields = []; - for (const col of this.allColumns.filter((col) => col.optional)) { + const optionalColumns = this.allColumns.filter((col) => + col.optional && !this.evalColumnInvisible(col.column_invisible)); + for (const col of optionalColumns) { const optionalField = { label: col.label, name: col.name, @@ -589,7 +595,8 @@ export class ListRenderer extends Component { } get hasOptionalFields() { - return this.allColumns.some((c) => c.optional); + return this.allColumns.some((col) => + col.optional && !this.evalColumnInvisible(col.column_invisible)); } get displayOptionalFields() { @@ -796,7 +803,7 @@ export class ListRenderer extends Component { getRowClass(record) { // classnames coming from decorations const classNames = this.props.archInfo.decorations - .filter((decoration) => evaluateExpr(decoration.condition, record.evalContext)) + .filter((decoration) => evaluateBooleanExpr(decoration.condition, record.evalContext)) .map((decoration) => decoration.class); if (record.selected) { classNames.push("table-info"); @@ -840,8 +847,7 @@ export class ListRenderer extends Component { } const classNames = [...this.cellClassByColumn[column.id]]; if (column.type === "field") { - const { required } = column.modifiers; - if (required && evalDomain(required, record.evalContext)) { + if (evaluateBooleanExpr(column.required, record.evalContext)) { classNames.push("o_required_modifier"); } if (record.isFieldInvalid(column.name)) { @@ -856,7 +862,7 @@ export class ListRenderer extends Component { // only handle the text-decoration. const { decorations } = column; for (const decoName in decorations) { - if (evaluateExpr(decorations[decoName], record.evalContext)) { + if (evaluateBooleanExpr(decorations[decoName], record.evalContext)) { classNames.push(getClassNameFromDecoration(decoName)); } } @@ -875,10 +881,10 @@ export class ListRenderer extends Component { } isCellReadonly(column, record) { - return ( + return !!( this.isRecordReadonly(record) || (column.relatedPropertyField && record.selected && record.model.multiEdit) || - evalDomain(column.modifiers.readonly, record.evalContext) + evaluateBooleanExpr(column.readonly, record.evalContext) ); } @@ -910,8 +916,12 @@ export class ListRenderer extends Component { return getFormattedValue(record, fieldName, column.attrs); } - evalModifier(modifier, record) { - return !!(modifier && new Domain(modifier).contains(record.evalContext)); + evalInvisible(invisible, record) { + return evaluateBooleanExpr(invisible, record.evalContext); + } + + evalColumnInvisible(columnInvisible) { + return this.props.evalViewModifier(columnInvisible); } getGroupDisplayName(group) { @@ -2106,6 +2116,7 @@ ListRenderer.props = [ "list", "archInfo", "openRecord", + "evalViewModifier", "onAdd?", "cycleOnTab?", "allowSelectors?", diff --git a/addons/web/static/src/views/list/list_renderer.xml b/addons/web/static/src/views/list/list_renderer.xml index d95e453e488..eeff5f8ac73 100644 --- a/addons/web/static/src/views/list/list_renderer.xml +++ b/addons/web/static/src/views/list/list_renderer.xml @@ -176,7 +176,7 @@ ()
    - +
  • -
  • - Modifiers: - +
  • + Invisible: + +
  • +
  • + Column invisible: + +
  • +
  • + Required: + +
  • +
  • + Readonly: +
  • Special: diff --git a/addons/web/static/src/views/view_compiler.js b/addons/web/static/src/views/view_compiler.js index 96fb448a0f9..83c8f73f071 100644 --- a/addons/web/static/src/views/view_compiler.js +++ b/addons/web/static/src/views/view_compiler.js @@ -140,11 +140,7 @@ export function encodeObjectForTemplate(obj) { * @returns {boolean | boolean[]} */ export function getModifier(el, modifierName) { - // cf python side def transfer_node_to_modifiers - // modifiers' string are evaluated to their boolean or array form - const modifiers = JSON.parse(el.getAttribute("modifiers") || "{}"); - const mod = modifierName in modifiers ? modifiers[modifierName] : false; - return typeof mod !== "boolean" ? mod : !!mod; + return el.getAttribute(modifierName); } /** @@ -229,16 +225,16 @@ export class ViewCompiler { * @returns {Element} */ applyInvisible(invisible, compiled, params) { - if (!invisible) { + if (!invisible || invisible === "False") { return compiled; } - if (typeof invisible === "boolean") { + if (invisible === "True" || invisible === "1") { return; } const recordExpr = params.recordExpr || "__comp__.props.record"; - let isVisileExpr = `!__comp__.evalDomainFromRecord(${recordExpr},${JSON.stringify( + let isVisileExpr = `!__comp__.evaluateBooleanExpr(${JSON.stringify( invisible - )})`; + )},${recordExpr}.evalContext)`; if (compiled.hasAttribute("t-if")) { const formerTif = compiled.getAttribute("t-if"); isVisileExpr = `( ${formerTif} ) and ${isVisileExpr}`; @@ -275,7 +271,7 @@ export class ViewCompiler { let invisible; if (evalInvisible) { invisible = getModifier(node, "invisible"); - if (this.isAlwaysInvisible(invisible, params)) { + if (!params.compileInvisibleNodes && (invisible === "True" || invisible === "1")) { return; } } @@ -394,7 +390,7 @@ export class ViewCompiler { */ compileGenericNode(el, params) { const compiled = createElement(el.nodeName.toLowerCase()); - const metaAttrs = ["modifiers", "attrs", "invisible", "readonly"]; + const metaAttrs = ["column_invisible", "invisible", "readonly", "required"]; for (const attr of el.attributes) { if (metaAttrs.includes(attr.name)) { continue; @@ -429,15 +425,6 @@ export class ViewCompiler { return assignOwlDirectives(widget, el); } - /** - * @param {any} invisibleModifer - * @param {{ enableInvisible?: boolean }} params - * @returns {boolean} - */ - isAlwaysInvisible(invisibleModifer, params) { - return !params.enableInvisible && typeof invisibleModifer === "boolean" && invisibleModifer; - } - validateNode(node) { // detect attributes not in whitelist, starting with t- const attributes = Object.values(node.attributes).map((attr) => attr.name); diff --git a/addons/web/static/src/views/widgets/widget.js b/addons/web/static/src/views/widgets/widget.js index 0cb5a77f1c4..63903d4ed62 100644 --- a/addons/web/static/src/views/widgets/widget.js +++ b/addons/web/static/src/views/widgets/widget.js @@ -1,7 +1,6 @@ /* @odoo-module */ -import { evalDomain } from "@web/core/domain"; -import { evaluateExpr } from "@web/core/py_js/py"; +import { evaluateExpr, evaluateBooleanExpr } from "@web/core/py_js/py"; import { registry } from "@web/core/registry"; import { Component, xml } from "@odoo/owl"; @@ -45,8 +44,7 @@ export class Widget extends Component { let propsFromNode = {}; if (this.props.widgetInfo) { const widgetInfo = this.props.widgetInfo; - const modifiers = widgetInfo.modifiers || {}; - readonlyFromModifiers = evalDomain(modifiers.readonly, evalContext); + readonlyFromModifiers = evaluateBooleanExpr(widgetInfo.attrs.readonly, evalContext); propsFromNode = this.widget.extractProps ? this.widget.extractProps(widgetInfo) : {}; } @@ -67,7 +65,6 @@ Widget.parseWidgetNode = function (node) { const widget = viewWidgetRegistry.get(name); const widgetInfo = { name, - modifiers: {}, widget, options: {}, attrs: {}, @@ -78,9 +75,7 @@ Widget.parseWidgetNode = function (node) { // avoid adding name and widget to attrs continue; } - if (name === "modifiers") { - widgetInfo.modifiers = JSON.parse(value); - } else if (name === "options") { + if (name === "options") { widgetInfo.options = evaluateExpr(value); } else if (!name.startsWith("t-att")) { // all other (non dynamic) attributes diff --git a/addons/web/static/tests/core/py_js/py_date_tests.js b/addons/web/static/tests/core/py_js/py_date_tests.js index 78c4e287620..3e60d970dac 100644 --- a/addons/web/static/tests/core/py_js/py_date_tests.js +++ b/addons/web/static/tests/core/py_js/py_date_tests.js @@ -333,7 +333,7 @@ QUnit.module("py", {}, () => { QUnit.test("throws when period negative", (assert) => { const matcher = (errorMessage) => { return function match(err) { - return err.message === errorMessage; + return err.message.includes(errorMessage); }; }; diff --git a/addons/web/static/tests/core/py_js/py_interpreter_tests.js b/addons/web/static/tests/core/py_js/py_interpreter_tests.js index 6d795c41375..41bc41ce980 100644 --- a/addons/web/static/tests/core/py_js/py_interpreter_tests.js +++ b/addons/web/static/tests/core/py_js/py_interpreter_tests.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import { evaluateExpr } from "@web/core/py_js/py"; +import { evaluateExpr, evaluateBooleanExpr } from "@web/core/py_js/py"; QUnit.module("py", {}, () => { QUnit.module("interpreter", () => { @@ -29,6 +29,8 @@ QUnit.module("py", {}, () => { assert.strictEqual(evaluateExpr('""'), ""); assert.strictEqual(evaluateExpr('"foo"'), "foo"); assert.strictEqual(evaluateExpr("'foo'"), "foo"); + assert.strictEqual(evaluateExpr("'FOO'.lower()"), "foo"); + assert.strictEqual(evaluateExpr("'foo'.upper()"), "FOO"); }); QUnit.test("boolean", (assert) => { @@ -96,6 +98,7 @@ QUnit.module("py", {}, () => { assert.strictEqual(evaluateExpr("not False"), true); assert.strictEqual(evaluateExpr("not foo", { foo: false }), true); assert.strictEqual(evaluateExpr("not None"), true); + assert.strictEqual(evaluateExpr("not []"), true); assert.strictEqual(evaluateExpr("True == False or True == True"), true); assert.strictEqual(evaluateExpr("False == True and False"), false); }); @@ -123,6 +126,7 @@ QUnit.module("py", {}, () => { assert.strictEqual(evaluateExpr('None or "bar"'), "bar"); assert.strictEqual(evaluateExpr("False or None"), null); assert.strictEqual(evaluateExpr("0 or 1"), 1); + assert.strictEqual(evaluateExpr("[] or False"), false); }); QUnit.module("values from context"); @@ -321,6 +325,22 @@ QUnit.module("py", {}, () => { assert.strictEqual(evaluateExpr("{'a': 1}.get('b', 54)"), 54); }); + QUnit.test("can get values from values 'context'", (assert) => { + assert.strictEqual(evaluateExpr("context.get('a')", { context: { a: 123 } }), 123); + const values = { context: { a: { b: { c: 321 } } } }; + assert.strictEqual(evaluateExpr("context.get('a').b.c", values), 321); + assert.strictEqual(evaluateExpr("context.get('a', {'e': 5}).b.c", values), 321); + assert.strictEqual(evaluateExpr("context.get('d', 3)", values), 3); + assert.strictEqual(evaluateExpr("context.get('d', {'e': 5})['e']", values), 5); + }); + + QUnit.test("can check if a key is in the 'context'", (assert) => { + assert.strictEqual(evaluateExpr("'a' in context", { context: { a: 123 } }), true); + assert.strictEqual(evaluateExpr("'a' in context", { context: { b: 123 } }), false); + assert.strictEqual(evaluateExpr("'a' not in context", { context: { a: 123 } }), false); + assert.strictEqual(evaluateExpr("'a' not in context", { context: { b: 123 } }), true); + }); + QUnit.module("objects"); QUnit.test("can read values from object", (assert) => { @@ -350,5 +370,42 @@ QUnit.module("py", {}, () => { QUnit.test("tuple in list", (assert) => { assert.deepEqual(evaluateExpr("[(1 + 2,'foo', True)]"), [[3, "foo", true]]); }); + + QUnit.module("evaluate to boolean"); + + QUnit.test("simple expression", (assert) => { + assert.strictEqual(evaluateBooleanExpr("12"), true); + assert.strictEqual(evaluateBooleanExpr("0"), false); + assert.strictEqual(evaluateBooleanExpr("0 + 3 - 1"), true); + assert.strictEqual(evaluateBooleanExpr("0 + 3 - 1 - 2"), false); + assert.strictEqual(evaluateBooleanExpr('"foo"'), true); + assert.strictEqual(evaluateBooleanExpr("[1]"), true); + assert.strictEqual(evaluateBooleanExpr("[]"), false); + }); + + QUnit.test("use contextual values", (assert) => { + assert.strictEqual(evaluateBooleanExpr("a", {a: 12}), true); + assert.strictEqual(evaluateBooleanExpr("a", {a: 0}), false); + assert.strictEqual(evaluateBooleanExpr("0 + 3 - a", {a: 1}), true); + assert.strictEqual(evaluateBooleanExpr("0 + 3 - a - 2", {a: 1}), false); + assert.strictEqual(evaluateBooleanExpr("0 + 3 - a - b", {a: 1, b: 2}), false); + assert.strictEqual(evaluateBooleanExpr('a', {a: "foo"}), true); + assert.strictEqual(evaluateBooleanExpr("a", {a: [1]}), true); + assert.strictEqual(evaluateBooleanExpr("a", {a: []}), false); + }); + + QUnit.test("throw if has missing value", (assert) => { + assert.throws(() => evaluateBooleanExpr("a", {b: 0})); + assert.strictEqual(evaluateBooleanExpr("1 or a"), true); // do not throw (lazy value) + assert.throws(() => evaluateBooleanExpr("0 or a")); + assert.throws(() => evaluateBooleanExpr("a or b", {b: true})); + assert.throws(() => evaluateBooleanExpr("a and b", {b: true})); + assert.throws(() => evaluateBooleanExpr("a()")); + assert.throws(() => evaluateBooleanExpr("a[0]")); + assert.throws(() => evaluateBooleanExpr("a.b")); + assert.throws(() => evaluateBooleanExpr("0 + 3 - a", {b: 1})); + assert.throws(() => evaluateBooleanExpr("0 + 3 - a - 2", {b: 1})); + assert.throws(() => evaluateBooleanExpr("0 + 3 - a - b", {b: 2})); + }); }); }); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index 3447eb802a4..591b09cab0a 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -10,7 +10,6 @@ import { serializeDate, serializeDateTime, } from "@web/core/l10n/dates"; -import { evaluateExpr } from "@web/core/py_js/py"; import { registry } from "@web/core/registry"; import { intersection, unique } from "@web/core/utils/arrays"; import { deepCopy, pick } from "@web/core/utils/objects"; @@ -319,7 +318,6 @@ export class MockServer { function isNodeProcessed(node) { return processedNodes.findIndex((n) => n.isSameNode(node)) > -1; } - const modifiersNames = ["invisible", "readonly", "required"]; const onchanges = params.models[modelName].onchanges || {}; const fieldNodes = {}; const groupbyNodes = {}; @@ -332,8 +330,6 @@ export class MockServer { } const editableView = editable && this._editableNode(doc, modelName); const onchangeAbleView = this._onchangeAbleView(doc); - const modifiersFromModel = this._modifiersFromModel(doc); - const inTreeView = ["tree", "list"].includes(doc.tagName); const inFormView = doc.tagName === "form"; // mock _postprocess_access_rights const isBaseModel = !context.base_model_name || modelName === context.base_model_name; @@ -350,90 +346,30 @@ export class MockServer { if (node.nodeType === Node.TEXT_NODE) { return false; } - const modifiers = {}; + ['required', 'readonly', 'invisible', 'column_invisible'].forEach((attr) => { + const value = node.getAttribute(attr); + if (value === '1' || value === 'true') { + node.setAttribute(attr, 'True'); + } + }); const isField = node.tagName === "field"; const isGroupby = node.tagName === "groupby"; if (isField) { const fieldName = node.getAttribute("name"); fieldNodes[fieldName] = { node, - isInvisible: node.getAttribute("invisible"), + isInvisible: node.getAttribute("invisible") === "True", isEditable: editableView && this._editableNode(node, modelName), }; - // 'transfer_field_to_modifiers' simulation const field = fields[fieldName]; if (!field) { throw new Error("Field " + fieldName + " does not exist"); } - const defaultValues = {}; - const stateExceptions = {}; // what is this ? - - modifiersFromModel.forEach((attr) => { - stateExceptions[attr] = []; - defaultValues[attr] = !!field[attr]; - }); - // LPE: what is this ? - /* for (const [state, modifs] of Object.entries(field.states || {})) { - modifs.forEach((modif) => { - if (defaultValues[modif[0]] !== modif[1]) { - stateExceptions[modif[0]].append(state); - } - }); - });*/ - Object.entries(defaultValues).forEach(([attr, defaultValue]) => { - if (stateExceptions[attr].length) { - modifiers[attr] = [ - ["state", defaultValue ? "not in" : "in", stateExceptions[attr]], - ]; - } else { - modifiers[attr] = defaultValue; - } - }); } else if (isGroupby && !isNodeProcessed(node)) { const groupbyName = node.getAttribute("name"); fieldNodes[groupbyName] = { node }; groupbyNodes[groupbyName] = node; } - // 'transfer_node_to_modifiers' simulation - let attrs = node.getAttribute("attrs"); - node.removeAttribute("attrs"); - if (attrs) { - attrs = evaluateExpr(attrs); - Object.assign(modifiers, attrs); - } - const states = node.getAttribute("states"); - node.removeAttribute("states"); - if (states) { - if (!modifiers.invisible) { - modifiers.invisible = []; - } - modifiers.invisible.push(["state", "not in", states.split(",")]); - } - const inListHeader = inTreeView && node.closest("header"); - modifiersNames.forEach((attr) => { - const mod = node.getAttribute(attr); - node.removeAttribute(attr); - if (mod) { - const v = evaluateExpr(mod, context) ? true : false; - if (inTreeView && !inListHeader && attr === "invisible") { - modifiers.column_invisible = v; - } else if (v || !(attr in modifiers) || !Array.isArray(modifiers[attr])) { - modifiers[attr] = v; - } - } - }); - modifiersNames.forEach((attr) => { - if ( - attr in modifiers && - (!!modifiers[attr] === false || - (Array.isArray(modifiers[attr]) && !modifiers[attr].length)) - ) { - delete modifiers[attr]; - } - }); - if (Object.keys(modifiers).length) { - node.setAttribute("modifiers", JSON.stringify(modifiers)); - } if (isGroupby && !isNodeProcessed(node)) { return false; } @@ -480,8 +416,7 @@ export class MockServer { Array.from(node.children).forEach((childNode) => { if (childNode.tagName) { relFields = Object.assign({}, params.models[relModel].fields); - // this is hackhish, but _getView modifies the subview document in place, - // especially to generate the "modifiers" attribute + // this is hackhish, but _getView modifies the subview document in place const { models } = this._getView({ models: params.models, arch: childNode, @@ -553,16 +488,7 @@ export class MockServer { case "field": { const fname = node.getAttribute("name"); const field = this.models[modelName].fields[fname]; - return ( - (!field.readonly || - (field.states && - Object.values(field.states).some((item) => - item.includes("readonly") - ))) && - (!["1", "True"].includes(node.getAttribute("readonly")) || - Object.keys(evaluateExpr(node.getAttribute("attrs") || "{}") || {}).length > - 0) - ); + return !field.readonly && node.getAttribute("readonly") !== "True" && node.getAttribute("readonly") !== "1"; } default: return false; @@ -579,14 +505,6 @@ export class MockServer { } } - _modifiersFromModel(node) { - const modifiersNames = ["invisible"]; - if (["kanban", "tree", "form"].includes(node.tagName)) { - modifiersNames.push(...["readonly", "required"]); - } - return modifiersNames; - } - /** * Converts an Object representing a record to actual return Object of the * python `onchange` method. diff --git a/addons/web/static/tests/mobile/views/form_view_tests.js b/addons/web/static/tests/mobile/views/form_view_tests.js index c4b418a6594..873dce7fbe7 100644 --- a/addons/web/static/tests/mobile/views/form_view_tests.js +++ b/addons/web/static/tests/mobile/views/form_view_tests.js @@ -104,8 +104,8 @@ QUnit.module("Mobile Views", ({ beforeEach }) => { arch: `
    -
    @@ -145,7 +145,7 @@ QUnit.module("Mobile Views", ({ beforeEach }) => { arch: `
    -
    @@ -197,7 +197,7 @@ QUnit.module("Mobile Views", ({ beforeEach }) => {
    -
    @@ -251,8 +251,8 @@ QUnit.module("Mobile Views", ({ beforeEach }) => {
    diff --git a/addons/web/static/tests/mock_server_tests.js b/addons/web/static/tests/mock_server_tests.js index 57fd9cabcf8..35f0da50a35 100644 --- a/addons/web/static/tests/mock_server_tests.js +++ b/addons/web/static/tests/mock_server_tests.js @@ -1377,14 +1377,14 @@ QUnit.module("MockServer", (hooks) => { data.views = { "bar,10001,list": ` - + `, "bar,10001,search": ``, }; const expectedList = ` - + `; const mockServer = new MockServer(data); diff --git a/addons/web/static/tests/views/calendar/calendar_view_tests.js b/addons/web/static/tests/views/calendar/calendar_view_tests.js index 1666c5f36c6..dc79d67790d 100644 --- a/addons/web/static/tests/views/calendar/calendar_view_tests.js +++ b/addons/web/static/tests/views/calendar/calendar_view_tests.js @@ -288,11 +288,11 @@ QUnit.module("Views", ({ beforeEach }) => { - + - + @@ -301,8 +301,8 @@ QUnit.module("Views", ({ beforeEach }) => { "event,1,form": ` - - + + `, }, @@ -1370,8 +1370,8 @@ QUnit.module("Views", ({ beforeEach }) => { - - + + `, }); @@ -4692,7 +4692,8 @@ QUnit.module("Views", ({ beforeEach }) => { serverData, arch: ` - + + `, }); @@ -4709,7 +4710,8 @@ QUnit.module("Views", ({ beforeEach }) => { serverData, arch: ` - + + `, }); diff --git a/addons/web/static/tests/views/fields/char_field_tests.js b/addons/web/static/tests/views/fields/char_field_tests.js index 1f890b101d4..dc84c95564b 100644 --- a/addons/web/static/tests/views/fields/char_field_tests.js +++ b/addons/web/static/tests/views/fields/char_field_tests.js @@ -429,7 +429,7 @@ QUnit.module("Fields", (hooks) => { - + `, diff --git a/addons/web/static/tests/views/fields/copy_clipboard_field_tests.js b/addons/web/static/tests/views/fields/copy_clipboard_field_tests.js index 21b5ef13e46..ae61cc9f9aa 100644 --- a/addons/web/static/tests/views/fields/copy_clipboard_field_tests.js +++ b/addons/web/static/tests/views/fields/copy_clipboard_field_tests.js @@ -294,7 +294,7 @@ Ho-ho-hoooo Merry Christmas`,
    - +
    diff --git a/addons/web/static/tests/views/fields/daterange_field_tests.js b/addons/web/static/tests/views/fields/daterange_field_tests.js index 294c6a50a9d..bfc1081e786 100644 --- a/addons/web/static/tests/views/fields/daterange_field_tests.js +++ b/addons/web/static/tests/views/fields/daterange_field_tests.js @@ -871,7 +871,7 @@ QUnit.module("Fields", (hooks) => { serverData, arch: /* xml */ `
    - + `, resId: 1, }); diff --git a/addons/web/static/tests/views/fields/many2many_checkboxes_field_tests.js b/addons/web/static/tests/views/fields/many2many_checkboxes_field_tests.js index 72175c40983..2b6113a785b 100644 --- a/addons/web/static/tests/views/fields/many2many_checkboxes_field_tests.js +++ b/addons/web/static/tests/views/fields/many2many_checkboxes_field_tests.js @@ -104,7 +104,7 @@ QUnit.module("Fields", (hooks) => { arch: `
    - +
    `, }); diff --git a/addons/web/static/tests/views/fields/monetary_field_tests.js b/addons/web/static/tests/views/fields/monetary_field_tests.js index 3906b3b4665..a9927de3b4e 100644 --- a/addons/web/static/tests/views/fields/monetary_field_tests.js +++ b/addons/web/static/tests/views/fields/monetary_field_tests.js @@ -400,7 +400,7 @@ QUnit.module("Fields", (hooks) => { arch: ` - + `, }); @@ -495,7 +495,7 @@ QUnit.module("Fields", (hooks) => { arch: ` - + `, }); 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 c378319063b..77f4fddd7ef 100644 --- a/addons/web/static/tests/views/fields/one2many_field_tests.js +++ b/addons/web/static/tests/views/fields/one2many_field_tests.js @@ -436,7 +436,7 @@ QUnit.module("Fields", (hooks) => { ); QUnit.test("O2M with parented m2o and domain on parent.m2o", async function (assert) { - assert.expect(4); + assert.expect(7); // Records in an o2m can have a m2o pointing to themselves. // In that case, a domain evaluation on that field followed by name_search @@ -475,6 +475,7 @@ QUnit.module("Fields", (hooks) => { mockRPC(route, { kwargs }) { if (route === "/web/dataset/call_kw/turtle/name_search") { assert.deepEqual(kwargs.args, [["id", "in", []]]); + assert.deepEqual(JSON.stringify(kwargs.args), '[["id","in",[]]]'); } }, }); @@ -648,8 +649,8 @@ QUnit.module("Fields", (hooks) => { - - + + `, @@ -1502,7 +1503,7 @@ QUnit.module("Fields", (hooks) => { - +
    `, @@ -2310,7 +2311,7 @@ QUnit.module("Fields", (hooks) => { serverData.views = { "turtle,false,form": `
    - + `, }; @@ -5258,7 +5259,7 @@ QUnit.module("Fields", (hooks) => {
    - + @@ -5322,7 +5323,7 @@ QUnit.module("Fields", (hooks) => { - + @@ -6520,7 +6521,7 @@ QUnit.module("Fields", (hooks) => { - +
    `, @@ -7856,7 +7857,7 @@ QUnit.module("Fields", (hooks) => { - + `, @@ -8343,7 +8344,7 @@ QUnit.module("Fields", (hooks) => { arch: `
    - + @@ -10615,8 +10616,8 @@ QUnit.module("Fields", (hooks) => { - - + + @@ -10667,7 +10668,7 @@ QUnit.module("Fields", (hooks) => { - - @@ -1917,7 +1921,7 @@ QUnit.module("Views", (hooks) => { - @@ -2099,8 +2103,8 @@ QUnit.module("Views", (hooks) => { - @@ -2129,7 +2133,7 @@ QUnit.module("Views", (hooks) => {
    -
    @@ -2356,8 +2360,8 @@ QUnit.module("Views", (hooks) => { - - + + `, mockRPC(route, args) { @@ -2412,8 +2416,8 @@ QUnit.module("Views", (hooks) => { - - + + `, mockRPC(route, args) { @@ -2466,8 +2470,8 @@ QUnit.module("Views", (hooks) => { - - + + `, mockRPC(route, args) { @@ -2627,7 +2631,7 @@ QUnit.module("Views", (hooks) => {
    - + @@ -2668,7 +2672,7 @@ QUnit.module("Views", (hooks) => { serverData, arch: ` - + `, }); @@ -2722,7 +2726,7 @@ QUnit.module("Views", (hooks) => { arch: `
    - + @@ -2770,7 +2774,7 @@ QUnit.module("Views", (hooks) => { - + @@ -2831,7 +2835,7 @@ QUnit.module("Views", (hooks) => {
    - +
    `, resId: 2, @@ -2912,9 +2916,9 @@ QUnit.module("Views", (hooks) => { `, @@ -2947,7 +2951,7 @@ QUnit.module("Views", (hooks) => {
    - + @@ -3063,7 +3067,7 @@ QUnit.module("Views", (hooks) => { - + @@ -3252,7 +3256,7 @@ QUnit.module("Views", (hooks) => {
    @@ -5899,7 +5903,7 @@ QUnit.module("Views", (hooks) => { - + @@ -6339,7 +6343,7 @@ QUnit.module("Views", (hooks) => { - + @@ -7580,7 +7584,7 @@ QUnit.module("Views", (hooks) => { serverData, arch: ` - + `, resId: 1, @@ -7664,7 +7668,7 @@ QUnit.module("Views", (hooks) => { arch: `
    - + `, resId: 5, }); @@ -7714,11 +7718,11 @@ QUnit.module("Views", (hooks) => { "partner,false,form": `
    -