[IMP] web: expression editor: model field selector

The component ExpressionEditorFieldSelector used by the expression editor
to allow the selection of a field has several drawbacks:
- the fields are not alphabetically ordered
- there is no possibility to (fuzzy search) a field
- the field types are not indicated in debug mode.
- the style is different from the classical style to be found in the
  domain selector.

Here we make the expression editor use a version of the ModelFieldSelector
component where the debug input is not shown. Indeed, this would require
some validation of the input value in order to prevent complex path like
user_id.company_id that are forbidden in studio for instance. Anyway,
there is not real need for such an input since it is easy to find/select
a simple path with the fuzzy search.

Task ID: 3599699

Part-of: odoo/odoo#142483
This commit is contained in:
Mathieu Duckerts-Antoine
2023-11-23 17:50:40 +00:00
parent 5d99de10ec
commit 40e0b4e324
8 changed files with 105 additions and 52 deletions
@@ -11,6 +11,8 @@ import { TreeEditor } from "@web/core/tree_editor/tree_editor";
import { getOperatorEditorInfo } from "@web/core/tree_editor/tree_editor_operator_editor";
import { getDefaultValue } from "@web/core/tree_editor/tree_editor_value_editors";
import { getDefaultPath } from "@web/core/tree_editor/utils";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import { _t } from "@web/core/l10n/translation";
function getDefaultCondition(fieldDefs) {
const defaultPath = getDefaultPath(fieldDefs);
@@ -20,15 +22,6 @@ function getDefaultCondition(fieldDefs) {
return condition(fieldDef.name, operator, value);
}
class ExpressionEditorFieldSelector extends Component {
static template = "web.ExpressionEditorFieldSelector";
static props = {
value: [String, { value: 1 }, { value: 0 }],
update: Function,
fields: Object,
};
}
export class ExpressionEditor extends Component {
static template = "web.ExpressionEditor";
static components = { TreeEditor };
@@ -45,7 +38,10 @@ export class ExpressionEditor extends Component {
}
async onPropsUpdated(props) {
this.defaultCondition = getDefaultCondition(props.fields);
this.filteredFields = Object.fromEntries(
Object.entries(props.fields).filter(([_, fieldDef]) => fieldDef.type !== "properties")
);
this.defaultCondition = getDefaultCondition(this.filteredFields);
try {
this.tree = treeFromExpression(props.expression, {
getFieldDef: (name) => this.getFieldDef(name, props),
@@ -75,14 +71,23 @@ export class ExpressionEditor extends Component {
getPathEditorInfo() {
return {
component: ExpressionEditorFieldSelector,
component: ModelFieldSelector,
extractProps: ({ value, update }) => ({
value,
path: value,
update,
fields: this.props.fields,
resModel: this.props.resModel,
readonly: false,
filter: (fieldDef) => fieldDef.name in this.filteredFields,
showDebugInput: false,
followRelations: false,
isDebugMode: this.isDebugMode,
}),
isSupported: (value) => [0, 1].includes(value) || this.getFieldDef(value),
// by construction, all values received by the path editor will be supported.
isSupported: (value) => [0, 1].includes(value) || value in this.filteredFields,
// by construction, all values received by the path editor are O/1 or a field (name) in this.props.fields.
// (see _leafFromAST in condition_tree.js)
stringify: (value) => this.props.fields[value].string,
defaultValue: () => this.defaultCondition.path,
message: _t("Field properties not supported"),
};
}
@@ -1,21 +1,6 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.ExpressionEditorFieldSelector">
<select class="o_input pe-3 text-truncate" t-on-change="(ev) => props.update(ev.target.value)">
<t t-if="[0, 1].includes(props.value)">
<option t-att-value="props.value" t-esc="props.value" selected="true" hidden="true" />
</t>
<t t-foreach="Object.values(props.fields)" t-as="field" t-key="field.name">
<option t-if="field.type !== 'properties' and !field.is_property"
t-att-value="field.name"
t-esc="field.string"
t-att-selected="field.name === props.value"
/>
</t>
</select>
</t>
<t t-name="web.ExpressionEditor">
<div class="o_expression_editor w-100" aria-atomic="true">
<t t-if="tree">
@@ -21,13 +21,14 @@ export class ModelFieldSelector extends Component {
update: { type: Function, optional: true },
filter: { type: Function, optional: true },
followRelations: { type: Boolean, optional: true },
showDebugInput: { type: Boolean, optional: true },
};
static defaultProps = {
readonly: true,
allowEmpty: false,
isDebugMode: false,
showSearchInput: true,
update: () => { },
update: () => {},
followRelations: true,
};
@@ -67,6 +68,7 @@ export class ModelFieldSelector extends Component {
isDebugMode: this.props.isDebugMode,
filter: this.props.filter,
followRelations: this.props.followRelations,
showDebugInput: this.props.showDebugInput,
});
}
@@ -94,6 +94,7 @@ export class ModelFieldSelectorPopover extends Component {
close: Function,
filter: { type: Function, optional: true },
followRelations: { type: Boolean, optional: true },
showDebugInput: { type: Boolean, optional: true },
isDebugMode: { type: Boolean, optional: true },
path: { optional: true },
resModel: String,
@@ -139,6 +140,10 @@ export class ModelFieldSelectorPopover extends Component {
);
}
get showDebugInput() {
return this.props.showDebugInput ?? this.props.isDebugMode;
}
filter(fieldDefs, path) {
const filteredKeys = Object.keys(fieldDefs).filter((k) =>
this.props.filter(fieldDefs[k], path)
@@ -50,7 +50,7 @@
</t>
</ul>
</div>
<t t-if="props.isDebugMode">
<t t-if="showDebugInput">
<div class="o_model_field_selector_popover_footer">
<input type="text" class="o_model_field_selector_debug o_input" t-att-value="state.page.path" t-on-change="(ev) => this.loadNewPath(ev.target.value)" t-on-keydown="onDebugInputKeydown" t-on-input="(ev) => this.props.update(ev.target.value, true)"/>
</div>
@@ -153,10 +153,6 @@ export function getCurrentPath(target, index = 0) {
if (pathEditor) {
if (pathEditor.querySelector(".o_model_field_selector")) {
return getModelFieldSelectorValues(pathEditor).join(" > ");
} else if (pathEditor.querySelector("select")) {
const select = pathEditor.querySelector("select");
const option = [...select.children].find((child) => child.selected);
return option.textContent;
}
return pathEditor.textContent;
}
@@ -3,7 +3,6 @@
import {
click,
editInput,
editSelect,
getFixture,
getNodesTextContent,
mount,
@@ -27,7 +26,10 @@ import {
setupConditionTreeEditorServices,
getOperatorOptions,
getValueOptions,
isNotSupportedPath,
clearNotSupported,
} from "./condition_tree_editor_helpers";
import { openModelFieldSelectorPopover } from "./model_field_selector_tests";
export {
clickOnButtonAddBranch,
@@ -39,6 +41,8 @@ export {
makeServerData,
selectOperator,
setupConditionTreeEditorServices,
isNotSupportedPath,
clearNotSupported,
} from "./condition_tree_editor_helpers";
let serverData;
@@ -49,11 +53,6 @@ export const SELECTORS = {
debugArea: ".o_expression_editor_debug_container textarea",
};
export async function selectPath(target, path, index = 0) {
const el = get(target, SELECTORS.pathEditor, index);
await editSelect(el, "select", path);
}
async function editExpression(target, value, index = 0) {
const input = get(target, SELECTORS.complexConditionInput, index);
await editInput(input, null, value);
@@ -208,7 +207,8 @@ QUnit.module("Components", (hooks) => {
{ level: 1, value: ["Bar", "is not", "blabla"] },
]);
const tree = getTreeEditorContent(target, { node: true });
await selectPath(tree[1].node, "foo");
await openModelFieldSelectorPopover(target);
await click(target.querySelectorAll(".o_model_field_selector_popover_item_name")[4]);
await selectOperator(tree[1].node, "not in");
await editValue(target, ["Doku", "Lukaku", "KDB"]);
assert.deepEqual(getTreeEditorContent(target), [
@@ -384,13 +384,8 @@ QUnit.module("Components", (hooks) => {
QUnit.test("check condition by default when creating a new rule", async (assert) => {
patchWithCleanup(odoo, { debug: false });
await makeExpressionEditor({
fields: {
...serverData.fields,
country_id: { string: "Country ID", type: "char" },
},
expression: "expr",
});
serverData.models.partner.fields.country_id = { string: "Country ID", type: "char" };
await makeExpressionEditor({ expression: "expr" });
await click(target, "a[role='button']");
assert.deepEqual(getTreeEditorContent(target), [
{ level: 0, value: "all" },
@@ -405,7 +400,8 @@ QUnit.module("Components", (hooks) => {
{ level: 0, value: "all" },
{ level: 1, value: ["ID", "is set"] },
]);
await selectPath(target, "bar");
await openModelFieldSelectorPopover(target);
await click(target.querySelector(".o_model_field_selector_popover_item_name"));
assert.deepEqual(getTreeEditorContent(target), [
{ level: 0, value: "all" },
{ level: 1, value: ["Bar", "is", "set"] },
@@ -419,4 +415,53 @@ QUnit.module("Components", (hooks) => {
assert.deepEqual(getOperatorOptions(target, -1), ["="]);
assert.deepEqual(getValueOptions(target, -1), ["1"]);
});
QUnit.test("no field of type properties in model field selector", async (assert) => {
patchWithCleanup(odoo, { debug: false });
serverData.models.partner.fields.properties = {
string: "Properties",
type: "properties",
definition_record: "product_id",
definition_record_field: "definitions",
searchable: true,
};
serverData.models.product.fields.definitions = {
string: "Definitions",
type: "properties_definition",
};
await makeExpressionEditor({
expression: `properties`,
fields: Object.fromEntries(
Object.entries(serverData.models.partner.fields).filter(([name]) =>
["bar", "foo", "properties"].includes(name)
)
),
update(expression) {
assert.step(expression);
},
});
assert.deepEqual(getTreeEditorContent(target), [
{
level: 0,
value: "all",
},
{
level: 1,
value: ["Properties", "is set"],
},
]);
assert.ok(isNotSupportedPath(target));
await clearNotSupported(target);
assert.verifySteps([`foo == ""`]);
await openModelFieldSelectorPopover(target);
assert.deepEqual(
getNodesTextContent([
...target.querySelectorAll(".o_model_field_selector_popover_item_name"),
]),
["Bar", "Foo"]
);
});
});
@@ -775,7 +775,7 @@ QUnit.module("Components", (hooks) => {
setup() {
this.state = useState({ path: `foo` });
}
update() { }
update() {}
}
Parent.components = { ModelFieldSelector };
Parent.template = xml`<ModelFieldSelector resModel="'partner'" readonly="false" path="state.path" update.bind="update"/>`;
@@ -1001,4 +1001,19 @@ QUnit.module("Components", (hooks) => {
assert.deepEqual(getModelFieldSelectorValues(target), ["foooooo"]);
assert.verifySteps(["foooooo"]);
});
QUnit.test("showDebugInput = false", async (assert) => {
await mountComponent(ModelFieldSelector, {
props: {
readonly: false,
path: "product_id",
resModel: "partner",
isDebugMode: true,
showDebugInput: false,
},
});
await openModelFieldSelectorPopover(target);
assert.containsNone(target, ".o_model_field_selector_debug");
});
});