[REF] web: simplify domain selector

This commit is a refactoring of the domain selector to simplify it.
The component DomainSelector now uses less sub components.
Consequently, the code is centralized in fewer files and more readable.

closes odoo/odoo#110978

Task: 3149707
Signed-off-by: Mathieu Duckerts-Antoine <dam@odoo.com>
This commit is contained in:
Michael (mcm)
2023-02-16 17:56:48 +01:00
parent 8926cecc6a
commit 2e2e300ad7
42 changed files with 949 additions and 1347 deletions
@@ -1,195 +1,29 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
import { DomainSelectorRootNode } from "./domain_selector_root_node";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { Component } from "@odoo/owl";
import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
import { Domain } from "@web/core/domain";
import { DomainTreeBuilder } from "@web/core/domain_selector/domain_tree_builder";
import {
getDefaultFieldValue,
getEditorInfo,
getOperatorsInfo,
} from "@web/core/domain_selector/domain_selector_fields";
import { BranchDomainNode } from "@web/core/domain_selector/domain_selector_nodes";
import { findOperator } from "@web/core/domain_selector/domain_selector_operators";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import { useService } from "@web/core/utils/hooks";
export class DomainSelector extends Component {
setup() {
this.nextNodeId = 0;
}
buildTree() {
try {
const domain = new Domain(this.props.value);
const ctx = {
parent: null,
index: 0,
domain: domain.toList(),
get currentElement() {
return ctx.domain[ctx.index];
},
next() {
ctx.index++;
},
getFullDomain() {
return rootNode.computeDomain().toString();
},
};
const rootNode = this.makeRootNode(ctx);
ctx.parent = rootNode;
this.traverseNode(ctx);
return ctx.parent;
} catch {
// WOWL TODO: rethrow error when not the expected type
return false;
}
}
traverseNode(ctx) {
if (ctx.index < ctx.domain.length) {
if (typeof ctx.currentElement === "string" && ["&", "|"].includes(ctx.currentElement)) {
this.traverseBranchNode(ctx);
} else {
this.traverseLeafNode(ctx);
}
}
}
traverseBranchNode(ctx) {
if (ctx.parent.type !== "branch" || ctx.parent.operator !== ctx.currentElement) {
const node = this.makeBranchNode(ctx, ctx.currentElement, []);
ctx.parent.operands.push(node);
ctx = Object.assign(Object.create(ctx), { parent: node });
}
ctx.next();
this.traverseNode(ctx);
ctx.next();
this.traverseNode(ctx);
}
traverseLeafNode(ctx) {
const condition = ctx.currentElement;
const [leftOperand, operator, rightOperand] = condition;
const node = this.makeLeafNode(ctx, operator, [leftOperand, rightOperand]);
ctx.parent.operands.push(node);
}
makeBranchNode(ctx, operator, operands) {
const updateDomain = () => this.props.update(ctx.getFullDomain());
const makeFakeNode = this.makeFakeNode.bind(this);
return {
type: "branch",
id: this.nextNodeId++,
operator,
operands,
computeDomain() {
return Domain.combine(
this.operands.map((operand) => operand.computeDomain()),
this.operator === "&" ? "AND" : "OR"
);
},
update(operator) {
this.operator = operator;
updateDomain();
},
insert(newNodeType) {
const newNode = makeFakeNode(ctx, newNodeType);
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this) + 1, 0, newNode);
updateDomain();
},
delete() {
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this), 1);
updateDomain();
},
};
}
makeLeafNode(ctx, operator, operands) {
const updateDomain = () => this.props.update(ctx.getFullDomain());
const makeFakeNode = this.makeFakeNode.bind(this);
return {
type: "leaf",
id: this.nextNodeId++,
operator,
operands,
computeDomain() {
return new Domain([[this.operands[0], this.operator, this.operands[1]]]);
},
update(changes) {
if ("fieldName" in changes) {
this.operands[0] = changes.fieldName;
}
if ("operator" in changes) {
this.operator = changes.operator;
}
if ("value" in changes) {
this.operands[1] = changes.value;
}
updateDomain();
},
insert(newNodeType) {
const newNode = makeFakeNode(ctx, newNodeType);
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this) + 1, 0, newNode);
updateDomain();
},
delete() {
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this), 1);
updateDomain();
},
};
}
makeRootNode(ctx) {
const updateDomain = (...args) => this.props.update(...args);
const makeFakeNode = this.makeFakeNode.bind(this);
return {
type: "root",
id: this.nextNodeId++,
operator: "&",
operands: [],
computeDomain() {
return Domain.combine(
this.operands.map((operand) => operand.computeDomain()),
"AND"
);
},
update(newValue, fromDebug) {
if (typeof newValue === "string") {
updateDomain(newValue, fromDebug);
} else if (this.operands.length) {
this.operands[0].update(newValue);
}
},
insert(newNodeType) {
const newNode = makeFakeNode(ctx, newNodeType);
if (ctx.parent) {
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this) + 1, 0, newNode);
} else {
this.operands.push(newNode);
}
updateDomain(ctx.getFullDomain());
},
delete() {},
};
}
makeFakeNode(ctx, type) {
const [field, op, value] = this.props.defaultLeafValue;
if (type === "branch") {
return this.makeBranchNode(ctx, ctx.parent.operator === "&" ? "|" : "&", [
this.makeLeafNode(ctx, op, [field, value]),
this.makeLeafNode(ctx, op, [field, value]),
]);
} else {
return this.makeLeafNode(ctx, op, [field, value]);
}
}
}
Object.assign(DomainSelector, {
template: "web._DomainSelector",
components: {
DomainSelectorRootNode,
},
props: {
static template = "web._DomainSelector";
static components = {
Dropdown,
DropdownItem,
ModelFieldSelector,
};
static props = {
className: { type: String, optional: true },
resModel: String,
value: String,
@@ -198,11 +32,186 @@ Object.assign(DomainSelector, {
update: { type: Function, optional: true },
isDebugMode: { type: Boolean, optional: true },
defaultLeafValue: { type: Array, optional: true },
},
defaultProps: {
};
static defaultProps = {
readonly: true,
update: () => {},
isDebugMode: false,
defaultLeafValue: ["id", "=", 1],
},
});
};
setup() {
this.view = useService("view");
this.treeBuilder = new DomainTreeBuilder();
this.tree = useState({
isSupported: false,
root: null,
});
onWillStart(() => this.onPropsUpdated(this.props));
onWillUpdateProps((np) => this.onPropsUpdated(np));
}
async onPropsUpdated(p) {
try {
// try to parse and execute the domain, if it fails then the domain is not supported.
const domain = new Domain(p.value);
domain.toList();
this.tree.root = this.treeBuilder.build(
domain,
await this.loadFieldDefs(p.resModel, this.extractFieldsFromDomain(domain))
);
this.defaultLeaf = this.treeBuilder.build(
new Domain([p.defaultLeafValue]),
await this.loadFieldDefs(p.resModel, [p.defaultLeafValue[0]])
).children[0];
this.tree.isSupported = true;
} catch {
this.tree.isSupported = false;
this.tree.root = null;
}
}
extractFieldsFromDomain(domain) {
const fields = [];
for (const node of domain.ast.value) {
if ([4, 10].includes(node.type)) {
if (node.value[0].type === 1 || [0, 1].includes(node.value[0].value)) {
fields.push(node.value[0].value);
}
}
}
return fields;
}
notifyChanges() {
this.props.update(this.tree.root.toDomain().toString());
}
async loadFieldDefs(resModel, fields) {
const promises = [];
const fieldDefs = {};
for (const field of fields) {
promises.push(
this.loadFieldDef(resModel, field).then((info) => {
fieldDefs[field] = info;
})
);
}
await Promise.all(promises);
return fieldDefs;
}
async loadFieldDef(resModel, field) {
if ("01".includes(field.toString())) {
return { type: "integer" };
}
const names = field.length ? field.split(".") : [];
let currentModel = resModel;
let fieldDef = null;
for (const name of names) {
const model = await this.view.loadFields(currentModel);
fieldDef = model[name];
currentModel = fieldDef.relation;
}
return fieldDef;
}
createNewLeaf() {
return this.defaultLeaf.clone();
}
createNewBranch(operator) {
return new BranchDomainNode(operator, [this.createNewLeaf(), this.createNewLeaf()]);
}
insertRootLeaf(parent) {
parent.add(this.createNewLeaf());
this.notifyChanges();
}
insertLeaf(parent, node) {
parent.insertAfter(node.id, this.createNewLeaf());
this.notifyChanges();
}
insertBranch(parent, node) {
const nextOperator = parent.operator === "AND" ? "OR" : "AND";
parent.insertAfter(node.id, this.createNewBranch(nextOperator));
this.notifyChanges();
}
delete(parent, node) {
parent.delete(node.id);
this.notifyChanges();
}
updateBranchOperator(node, operator) {
node.operator = operator;
this.notifyChanges();
}
async updateField(node, field) {
const fieldDef = await this.loadFieldDef(this.props.resModel, field);
node.field = { ...fieldDef, name: field };
node.operator = getOperatorsInfo(fieldDef.type)[0];
node.value = getDefaultFieldValue(fieldDef);
this.notifyChanges();
}
updateLeafOperator(node, operator) {
const previousOperator = node.operator;
node.operator = findOperator(operator);
if (previousOperator.valueMode !== node.operator.valueMode) {
switch (node.operator.valueMode) {
case "none": {
node.value = false;
break;
}
case "multiple": {
node.value = previousOperator.valueMode === "none" ? [] : [node.value];
break;
}
default: {
if (previousOperator.valueMode === "none") {
node.value = getDefaultFieldValue(node.field);
} else {
node.value = node.value[0];
}
break;
}
}
}
this.notifyChanges();
}
updateLeafValue(node, value) {
node.value = value;
this.notifyChanges();
}
onDebugValueChange(value) {
return this.props.update(value, true);
}
getEditorInfo(node) {
return getEditorInfo(node.field.type, node.operator.key);
}
getOperatorsInfo(node) {
const operators = getOperatorsInfo(node.field.type);
if (!operators.some((op) => op.key === node.operator.key)) {
operators.push(node.operator);
}
return operators;
}
highlightNode(target, toggle, classNames) {
const nodeEl = target.closest(".o_domain_node");
for (const className of classNames.split(/\s+/i)) {
nodeEl.classList.toggle(className, toggle);
}
}
}
@@ -2,21 +2,178 @@
<templates xml:space="preserve">
<t t-name="web._DomainSelector" owl="1">
<t t-set="tree" t-value="buildTree()"/>
<t t-if="tree">
<DomainSelectorRootNode
value="props.value"
node="tree"
readonly="props.readonly"
resModel="props.resModel"
isDebugMode="props.isDebugMode"
debugValue="props.debugValue"
className="props.className"
/>
<t t-if="tree.isSupported">
<t t-call="web._DomainSelector.root">
<t t-set="node" t-value="tree.root" />
</t>
</t>
<t t-else="">
<div t-att-class="props.className">This domain is not supported.</div>
</t>
</t>
<t t-name="web._DomainSelector.root" owl="1">
<div class="o_domain_node o_domain_tree o_domain_selector" aria-atomic="true" t-attf-class="{{ props.className }} {{ props.readonly ? 'o_read_mode' : 'o_edit_mode'}}" t-ref="root">
<t t-if="node.children.length === 0">
<span>Match <strong>all records</strong></span>
<t t-if="!props.readonly">
<button class="btn btn-sm btn-primary o_domain_add_first_node_button ms-1" t-on-click="() => this.insertRootLeaf(node)">
<i class="fa fa-plus"/> Add filter
</button>
</t>
</t>
<t t-elif="node.children.length === 1">
Match records with the following rule:
<t t-call="web._DomainSelector.branch.children" />
</t>
<t t-else="">
<span>Match records with </span>
<t t-call="web._DomainSelector.branch.operator" />
<span> of the following rules:</span>
<t t-call="web._DomainSelector.branch.children" />
</t>
<t t-if="props.isDebugMode and !props.readonly">
<label class="o_domain_debug_container">
<span class="small"># Code editor</span>
<textarea type="text" class="o_domain_debug_input" spellcheck="false" t-att-value="props.debugValue or props.value" t-on-change="(ev) => this.onDebugValueChange(ev.target.value)" />
</label>
</t>
</div>
</t>
<t t-name="web._DomainSelector.controls" owl="1">
<div class="o_domain_node_control_panel" role="toolbar" aria-label="Domain node">
<button
class="btn btn-link text-danger o_domain_delete_node_button"
title="Delete node"
aria-label="Delete node"
t-on-click="() => this.delete(parent, node)"
t-on-mouseenter="(ev) => this.highlightNode(ev.target, true, 'o_hover_btns')"
t-on-mouseleave="(ev) => this.highlightNode(ev.target, false, 'o_hover_btns')"
>
<i class="fa fa-times"/>
</button>
<button
class="btn o_domain_add_node_button"
title="Add node"
aria-label="Add node"
t-on-click="() => this.insertLeaf(parent, node)"
t-on-mouseenter="(ev) => this.highlightNode(ev.target, true, 'o_hover_add_node')"
t-on-mouseleave="(ev) => this.highlightNode(ev.target, false, 'o_hover_add_node')"
>
<i class="fa fa-plus-circle"/>
</button>
<button
class="btn o_domain_add_node_button"
title="Add branch"
aria-label="Add branch"
t-on-click="() => this.insertBranch(parent, node)"
t-on-mouseenter="(ev) => this.highlightNode(ev.target, true, 'o_hover_add_node o_hover_add_inset_node')"
t-on-mouseleave="(ev) => this.highlightNode(ev.target, false, 'o_hover_add_node o_hover_add_inset_node')"
>
<i class="fa fa-ellipsis-h"/>
</button>
</div>
</t>
<t t-name="web._DomainSelector.branch" owl="1">
<div class="o_domain_node o_domain_tree">
<div class="o_domain_tree_header o_domain_selector_row">
<t t-if="!props.readonly">
<t t-call="web._DomainSelector.controls" />
</t>
<t t-call="web._DomainSelector.branch.operator" />
<span class="ml4">of:</span>
</div>
<t t-call="web._DomainSelector.branch.children" />
</div>
</t>
<t t-name="web._DomainSelector.branch.operator" owl="1">
<t t-if="props.readonly">
<strong>
<t t-if="node.operator === 'AND'">all</t>
<t t-elif="node.operator === 'OR'">any</t>
<t t-elif="node.operator === 'NOT'">none</t>
</strong>
</t>
<t t-else="">
<div class="d-inline-flex o_domain_tree_operator_selector" aria-atomic="true">
<Dropdown togglerClass="'btn btn-link btn-sm btn-primary py-0 px-1 o_domain_tree_operator_caret'">
<t t-set-slot="toggler">
<t t-if="node.operator === 'AND'">all</t>
<t t-elif="node.operator === 'OR'">any</t>
<t t-elif="node.operator === 'NOT'">none</t>
</t>
<DropdownItem onSelected="() => this.updateBranchOperator(node, 'AND')">all</DropdownItem>
<DropdownItem onSelected="() => this.updateBranchOperator(node, 'OR')">any</DropdownItem>
</Dropdown>
</div>
</t>
</t>
<t t-name="web._DomainSelector.branch.children" owl="1">
<div class="o_domain_node_children_container">
<t t-foreach="node.children" t-as="child" t-key="child.id">
<t t-call="web._DomainSelector.{{ child.type }}">
<t t-set="parent" t-value="node" />
<t t-set="node" t-value="child" />
</t>
</t>
</div>
</t>
<t t-name="web._DomainSelector.leaf" owl="1">
<div class="o_domain_node o_domain_leaf o_domain_selector_row">
<t t-if="props.readonly">
<div class="o_domain_leaf_info">
<t t-call="web._DomainSelector.leaf:readonly" />
</div>
</t>
<t t-else="">
<t t-call="web._DomainSelector.controls" />
<div class="o_domain_leaf_edition">
<t t-call="web._DomainSelector.leaf:editable" />
</div>
</t>
</div>
</t>
<t t-name="web._DomainSelector.leaf:readonly" owl="1">
<ModelFieldSelector fieldName="node.field.name" resModel="props.resModel" readonly="true" />
<t t-if="typeof node.value === 'boolean'">
is <t t-if="node.value ? ['is_not', 'not_equal'].includes(node.operator.key) : ['is', 'equal'].includes(node.operator.key)"> not </t> set
</t>
<t t-else="">
<span class="o_domain_leaf_operator"> <t t-esc="node.operator.label" /></span>
<t t-set="values" t-value="Array.isArray(node.value) ? node.value : [node.value]" />
<t t-foreach="values" t-as="value" t-key="value_index">
<span class="o_domain_leaf_value text-primary">
<t t-if="typeof value === 'string'"> "<t t-esc="value"/>"</t>
<t t-else=""> <t t-esc="value"/></t>
</span>
<t t-if="!value_last"> or </t>
</t>
</t>
</t>
<t t-name="web._DomainSelector.leaf:editable" owl="1">
<ModelFieldSelector fieldName="node.field.name" resModel="props.resModel" readonly="false" update="(field) => this.updateField(node, field)" isDebugMode="props.isDebugMode" />
<div>
<select class="o_domain_leaf_operator_select o_input text-truncate pe-1" t-on-change="(ev) => this.updateLeafOperator(node, ev.target.value)">
<t t-foreach="getOperatorsInfo(node)" t-as="operator" t-key="operator.key">
<option t-att-value="operator.key" t-att-selected="node.operator.key === operator.key" t-esc="operator_value.label" />
</t>
</select>
</div>
<t t-if="node.operator.valueMode !== 'none'">
<div class="o_ds_value_cell">
<t t-set="editor" t-value="getEditorInfo(node)" />
<t t-component="editor.component" t-props="editor.props({ value: node.value, update: (value) => this.updateLeafValue(node, value), field: node.field })" />
</div>
</t>
</t>
</templates>
@@ -1,30 +0,0 @@
/** @odoo-module **/
import { DomainSelectorBranchOperator } from "./domain_selector_branch_operator";
import { DomainSelectorControlPanel } from "./domain_selector_control_panel";
import { DomainSelectorLeafNode } from "./domain_selector_leaf_node";
import { Component, useRef } from "@odoo/owl";
export class DomainSelectorBranchNode extends Component {
setup() {
this.root = useRef("root");
}
onHoverDeleteNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_btns", hovering);
}
onHoverInsertLeafNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_add_node", hovering);
}
onHoverInsertBranchNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_add_node", hovering);
this.root.el.classList.toggle("o_hover_add_inset_node", hovering);
}
}
DomainSelectorBranchNode.template = "web.DomainSelectorBranchNode";
DomainSelectorBranchNode.components = {
DomainSelectorBranchNode,
DomainSelectorBranchOperator,
DomainSelectorControlPanel,
DomainSelectorLeafNode,
};
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorBranchNode" owl="1">
<div class="o_domain_node o_domain_tree" t-ref="root">
<div class="o_domain_tree_header o_domain_selector_row">
<t t-if="!props.readonly">
<DomainSelectorControlPanel
node="props.node"
onHoverDeleteNodeBtn.bind="onHoverDeleteNodeBtn"
onHoverInsertLeafNodeBtn.bind="onHoverInsertLeafNodeBtn"
onHoverInsertBranchNodeBtn.bind="onHoverInsertBranchNodeBtn"
/>
</t>
<DomainSelectorBranchOperator node="props.node" readonly="props.readonly" />
<span class="ml4">of:</span>
</div>
<div class="o_domain_node_children_container">
<t t-foreach="props.node.operands" t-as="subNode" t-key="subNode.id">
<t t-if="subNode.type === 'branch'">
<DomainSelectorBranchNode t-props="{ ...props, node: subNode }" />
</t>
<t t-elif="subNode.type === 'leaf'">
<DomainSelectorLeafNode t-props="{ ...props, node: subNode }" />
</t>
</t>
</div>
</div>
</t>
</templates>
@@ -1,24 +0,0 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { Component } from "@odoo/owl";
export class DomainSelectorBranchOperator extends Component {
onOperatorSelected(operator) {
this.props.node.update(operator);
}
}
DomainSelectorBranchOperator.components = {
Dropdown,
DropdownItem,
};
DomainSelectorBranchOperator.template = "web.DomainSelectorBranchOperator";
DomainSelectorBranchOperator.props = {
node: Object,
readonly: Boolean,
showCaret: {
type: Boolean,
optional: true,
},
};
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorBranchOperator" owl="1">
<t t-if="!props.readonly">
<div class="d-inline-flex o_domain_tree_operator_selector" aria-atomic="true">
<Dropdown togglerClass="'btn btn-link btn-sm btn-primary py-0 px-1 o_domain_tree_operator_caret'" showCaret="props.showCaret">
<t t-set-slot="toggler">
<t t-if="props.node.operator === '&amp;'">all</t>
<t t-elif="props.node.operator === '|'">any</t>
<t t-elif="props.node.operator === '!'">none</t>
</t>
<DropdownItem onSelected="() => this.onOperatorSelected('&amp;')">all</DropdownItem>
<DropdownItem onSelected="() => this.onOperatorSelected('|')">any</DropdownItem>
</Dropdown>
</div>
</t>
<t t-else="">
<strong>
<t t-if="props.node.operator === '&amp;'">all</t>
<t t-elif="props.node.operator === '|'">any</t>
<t t-elif="props.node.operator === '!'">none</t>
</strong>
</t>
</t>
</templates>
@@ -1,39 +0,0 @@
/** @odoo-module **/
import { Component, toRaw } from "@odoo/owl";
export class DomainSelectorControlPanel extends Component {
deleteNode() {
this.props.node.delete();
}
insertNode(newNodeType) {
toRaw(this.props.node).insert(newNodeType); // FIXME WOWL reactivity
}
onEnterDeleteNodeBtn() {
this.props.onHoverDeleteNodeBtn(true);
}
onLeaveDeleteNodeBtn() {
this.props.onHoverDeleteNodeBtn(false);
}
onEnterInsertLeafNodeBtn() {
this.props.onHoverInsertLeafNodeBtn(true);
}
onLeaveInsertLeafNodeBtn() {
this.props.onHoverInsertLeafNodeBtn(false);
}
onEnterInsertBranchNodeBtn() {
this.props.onHoverInsertBranchNodeBtn(true);
}
onLeaveInsertBranchNodeBtn() {
this.props.onHoverInsertBranchNodeBtn(false);
}
}
DomainSelectorControlPanel.template = "web.DomainSelectorControlPanel";
DomainSelectorControlPanel.props = {
node: Object,
onHoverDeleteNodeBtn: Function,
onHoverInsertLeafNodeBtn: Function,
onHoverInsertBranchNodeBtn: Function,
};
@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorControlPanel" owl="1">
<div class="o_domain_node_control_panel" role="toolbar" aria-label="Domain node">
<button
class="btn btn-link text-danger o_domain_delete_node_button"
title="Delete node"
aria-label="Delete node"
t-on-click="deleteNode"
t-on-mouseenter="onEnterDeleteNodeBtn"
t-on-mouseleave="onLeaveDeleteNodeBtn"
>
<i class="fa fa-times"/>
</button>
<button
class="btn o_domain_add_node_button"
title="Add node"
aria-label="Add node"
t-on-click="() => this.insertNode('leaf')"
t-on-mouseenter="onEnterInsertLeafNodeBtn"
t-on-mouseleave="onLeaveInsertLeafNodeBtn"
>
<i class="fa fa-plus-circle"/>
</button>
<button
class="btn o_domain_add_node_button"
title="Add branch"
aria-label="Add branch"
data-branch="1"
t-on-click="() => this.insertNode('branch')"
t-on-mouseenter="onEnterInsertBranchNodeBtn"
t-on-mouseleave="onLeaveInsertBranchNodeBtn"
>
<i class="fa fa-ellipsis-h"/>
</button>
</div>
</t>
</templates>
@@ -0,0 +1,276 @@
/** @odoo-module **/
import { Component, useRef } from "@odoo/owl";
import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker";
import { selectOperators } from "@web/core/domain_selector/domain_selector_operators";
import {
deserializeDate,
deserializeDateTime,
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
import { _lt } from "@web/core/l10n/translation";
import { evaluateExpr, formatAST } from "@web/core/py_js/py";
import { toPyValue } from "@web/core/py_js/py_utils";
import { registry } from "@web/core/registry";
// ============================================================================
class Input extends Component {
static props = ["value", "update"];
static template = "web.DomainSelector.Input";
}
class Select extends Component {
static props = ["value", "update", "options"];
static template = "web.DomainSelector.Select";
deserialize(value) {
return JSON.parse(value);
}
serialize(value) {
return JSON.stringify(value);
}
}
class TagInput extends Component {
static props = ["value", "update"];
static template = "web.DomainSelector.TagInput";
setup() {
this.inputRef = useRef("input");
}
removeTag(tagIndex) {
return this.props.update([
...this.props.value.slice(0, tagIndex),
...this.props.value.slice(tagIndex + 1),
]);
}
addTag(value) {
return this.props.update([...this.props.value, value]);
}
onBtnClick() {
const value = this.inputRef.el.value;
this.inputRef.el.value = "";
return this.addTag(value);
}
}
// ============================================================================
const parsers = registry.category("parsers");
function parseValue(fieldType, value) {
const parser = parsers.get(fieldType, (value) => value);
try {
return parser(value);
} catch {
return value;
}
}
function makeEditor(component, props) {
return {
component,
props: props || (({ value, update }) => ({ value, update })),
};
}
const DEFAULT = {
operators: [
"equal",
"not_equal",
"greater_than",
"greater_equal",
"less_than",
"less_equal",
"ilike",
"not_ilike",
"like",
"not_like",
"equal_like",
"equal_ilike",
"child_of",
"parent_of",
"in",
"not_in",
"set",
"not_set",
],
editors: { default: makeEditor(Input) },
defaultValue: () => "",
};
// ----------------------------------------------------------------------------
const BOOLEAN = {
operators: ["is", "is_not"],
editors: {
default: makeEditor(Select, ({ value, update }) => ({
value,
update,
options: [
[true, _lt("set")],
[false, _lt("not set")],
],
})),
},
defaultValue: () => true,
};
// ----------------------------------------------------------------------------
const DATE = {
operators: [
"equal",
"not_equal",
"greater_than",
"greater_equal",
"less_than",
"less_equal",
"set",
"not_set",
],
editors: {
default: makeEditor(DatePicker, ({ value, update }) => ({
date: deserializeDate(value),
onDateTimeChanged: (value) => {
if (!value.isValid) {
return;
}
return update(value ? serializeDate(value) : luxon.DateTime.local());
},
})),
},
defaultValue: () => serializeDate(luxon.DateTime.local()),
};
// ----------------------------------------------------------------------------
const DATETIME = {
operators: [
"equal",
"not_equal",
"greater_than",
"greater_equal",
"less_than",
"less_equal",
"set",
"not_set",
],
editors: {
default: makeEditor(DateTimePicker, ({ value, update }) => ({
date: deserializeDateTime(value),
onDateTimeChanged: (value) => {
if (!value.isValid) {
return;
}
return update(value ? serializeDateTime(value) : luxon.DateTime.local());
},
})),
},
defaultValue: () => serializeDateTime(luxon.DateTime.local()),
};
// ----------------------------------------------------------------------------
const TEXT = {
operators: ["equal", "not_equal", "ilike", "not_ilike", "set", "not_set", "in", "not_in"],
editors: {
default: makeEditor(Input),
in: makeEditor(TagInput),
not_in: makeEditor(TagInput),
},
defaultValue: () => "",
};
// ----------------------------------------------------------------------------
const NUMBER = {
operators: [
"equal",
"not_equal",
"greater_than",
"greater_equal",
"less_than",
"less_equal",
"ilike",
"not_ilike",
"set",
"not_set",
],
editors: {
default: makeEditor(Input, ({ value, update, field }) => ({
value: `${value}`,
update: (value) => update(parseValue(field.type, value)),
})),
},
defaultValue: () => 1,
};
// ----------------------------------------------------------------------------
const RELATIONAL = {
operators: ["equal", "not_equal", "ilike", "not_ilike", "set", "not_set"],
editors: { default: makeEditor(Input) },
defaultValue: (field) => (field.type === "many2one" ? 1 : []),
};
// ----------------------------------------------------------------------------
const SELECTION_EDITOR_IN = makeEditor(Input, ({ value, update }) => ({
value: formatAST(toPyValue(value)),
update: (value) => update(evaluateExpr(value)),
}));
const SELECTION = {
operators: ["equal", "not_equal", "in", "not_in", "set", "not_set"],
editors: {
default: makeEditor(Select, ({ value, update, field }) => ({
value,
update,
options: field.selection,
})),
in: SELECTION_EDITOR_IN,
not_in: SELECTION_EDITOR_IN,
},
defaultValue: (field) => field.selection[0][0],
};
// ============================================================================
export const FIELD_DESCRIPTIONS = {
boolean: BOOLEAN,
char: TEXT,
date: DATE,
datetime: DATETIME,
float: NUMBER,
html: TEXT,
integer: NUMBER,
many2many: RELATIONAL,
many2one: RELATIONAL,
monetary: NUMBER,
one2many: RELATIONAL,
selection: SELECTION,
text: TEXT,
};
export function getFieldInfo(fieldType) {
return FIELD_DESCRIPTIONS[fieldType] || DEFAULT;
}
export function getEditorInfo(fieldType, operator) {
const fieldInfo = getFieldInfo(fieldType);
return fieldInfo.editors[operator] || fieldInfo.editors.default;
}
export function getOperatorsInfo(fieldType) {
return selectOperators(getFieldInfo(fieldType).operators);
}
export function getDefaultFieldValue(field) {
const desc = getFieldInfo(field.type);
return desc.defaultValue(field);
}
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelector.Input" owl="1">
<input type="text" class="o_input o_domain_leaf_value_input" t-att-value="props.value" t-on-change="(ev) => props.update(ev.target.value)" />
</t>
<t t-name="web.DomainSelector.Select" owl="1">
<select class="o_input o_domain_leaf_value_input" t-on-change="(ev) => props.update(deserialize(ev.target.value))">
<t t-foreach="props.options" t-as="option" t-key="option[0]">
<option t-att-value="serialize(option[0])" t-att-selected="option[0] === props.value" t-esc="option[1]" />
</t>
</select>
</t>
<t t-name="web.DomainSelector.TagInput" owl="1">
<div class="o_domain_leaf_value_input">
<t t-foreach="props.value" t-as="tag" t-key="tag_index">
<span class="badge rounded-pill">
<t t-esc="tag" />
<i class="o_domain_leaf_value_remove_tag_button fa fa-times" role="img" aria-label="Remove tag" title="Remove tag" t-on-click="() => this.removeTag(tag_index)" />
</span>
</t>
</div>
<div class="o_domain_leaf_value_tags">
<input type="text" class="o_input" placeholder="Add new value" t-ref="input" />
<button class="btn btn-sm btn-primary fa fa-plus o_domain_leaf_value_add_tag_button" aria-label="Add tag" title="Add tag" t-on-click="onBtnClick" />
</div>
</t>
</templates>
@@ -1,127 +0,0 @@
/** @odoo-module **/
import { useModelField } from "@web/core/model_field_selector/model_field_hook";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import { registry } from "@web/core/registry";
import { DomainSelectorControlPanel } from "./domain_selector_control_panel";
import { DomainSelectorDefaultField } from "./fields/domain_selector_default_field";
import { Component, onWillStart, onWillUpdateProps, useRef } from "@odoo/owl";
export class DomainSelectorLeafNode extends Component {
setup() {
this.root = useRef("root");
this.modelField = useModelField();
this.fieldInfo = {
type: "integer",
string: "ID",
};
onWillStart(async () => {
this.fieldInfo = await this.loadField(this.props.resModel, this.props.node.operands[0]);
});
onWillUpdateProps(async (nextProps) => {
this.fieldInfo = await this.loadField(nextProps.resModel, nextProps.node.operands[0]);
});
}
get displayedOperator() {
const op = this.getOperatorInfo(this.props.node.operator);
return op ? op.label : "?";
}
get isValueHidden() {
const op = this.getOperatorInfo(this.props.node.operator);
return op ? op.hideValue : false;
}
async loadField(resModel, fieldName) {
const chain = await this.modelField.loadChain(resModel, fieldName);
if (!chain[chain.length - 1].field && chain.length > 1) {
return chain[chain.length - 2].field;
}
return (
chain[chain.length - 1].field || {
type: "integer",
string: "ID",
}
);
}
findOperator(operatorList, opToFind) {
return operatorList.find((o) =>
o.matches({
field: this.fieldInfo,
value: this.props.node.operands[1],
operator: opToFind,
})
);
}
getOperators(field) {
const operators = field.getOperators();
if (this.findOperator(operators, this.props.node.operator)) {
return operators;
}
return operators.concat(
this.findOperator(
registry.category("domain_selector/operator").getAll(),
this.props.node.operator
)
);
}
getFieldComponent(type) {
return registry.category("domain_selector/fields").get(type, DomainSelectorDefaultField);
}
getOperatorInfo(operator) {
const op = this.findOperator(
this.getFieldComponent(this.fieldInfo.type).getOperators(),
operator
);
if (op) {
return op;
}
return this.findOperator(
registry.category("domain_selector/operator").getAll(),
this.props.node.operator
);
}
async onFieldChange(fieldName) {
const changes = { fieldName };
const fieldInfo = await this.loadField(this.props.resModel, fieldName);
const component = this.getFieldComponent(fieldInfo.type);
Object.assign(changes, component.onDidTypeChange(fieldInfo));
this.props.node.update(changes);
}
onOperatorChange(ev) {
const component = this.getFieldComponent(this.fieldInfo.type);
const operatorInfo = component.getOperators()[parseInt(ev.target.value, 10)];
const changes = { operator: operatorInfo.value };
Object.assign(
changes,
operatorInfo.onDidChange(this.getOperatorInfo(this.props.node.operator), () =>
component.onDidTypeChange(this.fieldInfo)
)
);
this.props.node.update(changes);
}
onHoverDeleteNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_btns", hovering);
}
onHoverInsertLeafNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_add_node", hovering);
}
onHoverInsertBranchNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_add_node", hovering);
this.root.el.classList.toggle("o_hover_add_inset_node", hovering);
}
}
Object.assign(DomainSelectorLeafNode, {
template: "web.DomainSelectorLeafNode",
components: {
DomainSelectorControlPanel,
ModelFieldSelector,
},
});
@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorLeafNode" owl="1">
<div class="o_domain_node o_domain_leaf o_domain_selector_row" t-ref="root">
<t t-if="props.readonly">
<div class="o_domain_leaf_info">
<ModelFieldSelector fieldName="props.node.operands[0].toString()" resModel="props.resModel" readonly="props.readonly" isDebugMode="props.isDebugMode" />
<t t-if="typeof props.node.operands[1] === 'string'">
<span class="o_domain_leaf_operator"> <t t-esc="displayedOperator" /></span>
<span class="o_domain_leaf_value text-primary"> "<t t-esc="props.node.operands[1]" />"</span>
</t>
<t t-elif="typeof props.node.operands[1] === 'number'">
<span class="o_domain_leaf_operator"> <t t-esc="displayedOperator" /></span>
<span class="o_domain_leaf_value text-primary"> <t t-esc="props.node.operands[1]" /></span>
</t>
<t t-elif="typeof props.node.operands[1] === 'boolean'">
is
<t t-if="(props.node.operator === '=' and props.node.operands[1] === false) or (props.node.operator === '!=' and props.node.operands[1] === true)">
not
</t>
set
</t>
<t t-elif="Array.isArray(props.node.operands[1])">
<span class="o_domain_leaf_operator"> <t t-esc="displayedOperator" /></span>
<t t-foreach="props.node.operands[1]" t-as="value" t-key="value_index">
<span class="o_domain_leaf_value text-primary"> "<t t-esc="value"/>"</span>
<t t-if="!value_last"> or </t>
</t>
</t>
</div>
</t>
<t t-else="">
<DomainSelectorControlPanel
node="props.node"
onHoverDeleteNodeBtn.bind="onHoverDeleteNodeBtn"
onHoverInsertLeafNodeBtn.bind="onHoverInsertLeafNodeBtn"
onHoverInsertBranchNodeBtn.bind="onHoverInsertBranchNodeBtn"
/>
<div class="o_domain_leaf_edition">
<ModelFieldSelector fieldName="props.node.operands[0].toString()" resModel="props.resModel" readonly="props.readonly" update="(name) => this.onFieldChange(name)" isDebugMode="props.isDebugMode" />
<t t-set="field" t-value="getFieldComponent(fieldInfo.type)" />
<t t-if="field">
<div>
<select class="o_domain_leaf_operator_select o_input text-truncate pe-1" t-on-change="onOperatorChange">
<t t-foreach="getOperators(field)" t-as="operator" t-key="operator.value">
<option
t-att-value="operator_index"
t-att-selected="operator.matches({ fieldInfo, operator: props.node.operator, value: props.node.operands[1] })"
t-esc="operator.label"
/>
</t>
</select>
</div>
<t t-if="!isValueHidden">
<t
t-component="field"
t-key="fieldInfo.type"
field="fieldInfo"
operator="getOperatorInfo(props.node.operator)"
value="props.node.operands[1]"
update="(changes) => props.node.update(changes)"
/>
</t>
</t>
</div>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,66 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
class DomainNode {
static nextId = 0;
constructor(type) {
this.id = ++DomainNode.nextId;
this.type = type;
}
toDomain() {
return new Domain();
}
}
// ----------------------------------------------------------------------------
export class BranchDomainNode extends DomainNode {
constructor(operator, children = []) {
super("branch");
this.operator = operator;
this.children = children;
}
toDomain() {
return Domain.combine(
this.children.map((c) => c.toDomain()),
this.operator
);
}
add(node) {
this.children.push(node);
}
insertAfter(id, node) {
const childIndex = this.children.findIndex((c) => c.id === id);
this.children.splice(childIndex + 1, 0, node);
}
delete(id) {
const childIndex = this.children.findIndex((c) => c.id === id);
this.children.splice(childIndex, 1);
}
}
// ----------------------------------------------------------------------------
export class LeafDomainNode extends DomainNode {
constructor(field, operator, value) {
super("leaf");
this.field = field;
this.operator = operator;
this.value = value;
}
toDomain() {
return new Domain([[this.field.name, this.operator.symbol, this.value]]);
}
clone() {
return new LeafDomainNode(this.field, this.operator, this.value);
}
}
@@ -1,167 +1,41 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
function onDidChange(action) {
return function (oldOperator, fieldChange) {
if (this.category !== oldOperator.category) {
return action(fieldChange);
}
return {};
};
export const OPERATOR_DESCRIPTIONS = [
{ key: "is", label: _lt("is"), symbol: "=" },
{ key: "is_not", label: _lt("is not"), symbol: "!=" },
{ key: "equal", label: "=", symbol: "=" },
{ key: "not_equal", label: "!=", symbol: "!=" },
{ key: "greater_than", label: ">", symbol: ">" },
{ key: "greater_equal", label: ">=", symbol: ">=" },
{ key: "less_than", label: "<", symbol: "<" },
{ key: "less_equal", label: "<=", symbol: "<=" },
{ key: "ilike", label: _lt("contains"), symbol: "ilike" },
{ key: "not_ilike", label: _lt("does not contain"), symbol: "not ilike" },
{ key: "like", label: _lt("like"), symbol: "like" },
{ key: "not_like", label: _lt("not like"), symbol: "not like" },
{ key: "equal_like", label: _lt("=like"), symbol: "=like" },
{ key: "equal_ilike", label: _lt("=ilike"), symbol: "=ilike" },
{ key: "child_of", label: _lt("child of"), symbol: "child_of" },
{ key: "parent_of", label: _lt("parent of"), symbol: "parent_of" },
{ key: "in", label: _lt("in"), symbol: "in", valueMode: "multiple" },
{ key: "not_in", label: _lt("not in"), symbol: "not in", valueMode: "multiple" },
{ key: "set", label: _lt("is set"), symbol: "!=", valueMode: "none" },
{ key: "not_set", label: _lt("is not set"), symbol: "=", valueMode: "none" },
];
export function findOperator(key) {
return OPERATOR_DESCRIPTIONS.find((op) => op.key === key);
}
function matchValue() {
return function ({ operator }) {
return operator === this.value;
};
export function selectOperators(keys) {
const operators = Object.fromEntries(OPERATOR_DESCRIPTIONS.map((op) => [op.key, op]));
return keys.map((key) => operators[key]);
}
const dso = registry.category("domain_selector/operator");
dso.add("=", {
category: "equality",
label: "=",
value: "=",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches({ operator, value }) {
return operator === this.value && typeof value !== "boolean";
},
});
dso.add("!=", {
category: "equality",
label: _lt("is not ="),
value: "!=",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches({ operator, value }) {
return operator === this.value && typeof value !== "boolean";
},
});
dso.add(">", {
category: "comparison",
label: ">",
value: ">",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches: matchValue(),
});
dso.add(">=", {
category: "comparison",
label: ">=",
value: ">=",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches: matchValue(),
});
dso.add("<", {
category: "comparison",
label: "<",
value: "<",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches: matchValue(),
});
dso.add("<=", {
category: "comparison",
label: "<=",
value: "<=",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches: matchValue(),
});
dso.add("ilike", {
category: "like",
label: _lt("contains"),
value: "ilike",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("not ilike", {
category: "like",
label: _lt("does not contain"),
value: "not ilike",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("like", {
category: "like",
label: _lt("like"),
value: "like",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("not like", {
category: "like",
label: _lt("not like"),
value: "not like",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("=like", {
category: "like",
label: _lt("=like"),
value: "=like",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("=ilike", {
category: "like",
label: _lt("=ilike"),
value: "=ilike",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("child_of", {
category: "relation",
label: _lt("child of"),
value: "child_of",
onDidChange: onDidChange(() => ({ value: 1 })),
matches: matchValue(),
});
dso.add("parent_of", {
category: "relation",
label: _lt("parent of"),
value: "parent_of",
onDidChange: onDidChange(() => ({ value: 1 })),
matches: matchValue(),
});
dso.add("in", {
category: "in",
label: _lt("in"),
value: "in",
onDidChange: onDidChange(() => ({ value: [] })),
matches: matchValue(),
});
dso.add("not in", {
category: "in",
label: _lt("not in"),
value: "not in",
onDidChange: onDidChange(() => ({ value: [] })),
matches: matchValue(),
});
dso.add("set", {
category: "set",
label: _lt("is set"),
value: "set",
hideValue: true,
onDidChange() {
return {
operator: "!=",
value: false,
};
},
matches({ operator, value }) {
return operator === "!=" && typeof value === "boolean";
},
});
dso.add("not set", {
category: "set",
label: _lt("is not set"),
value: "not set",
hideValue: true,
onDidChange() {
return {
operator: "=",
value: false,
};
},
matches({ operator, value }) {
return operator === "=" && typeof value === "boolean";
},
});
export function parseOperator(symbol) {
return OPERATOR_DESCRIPTIONS.filter(
(op) => !["is", "is_not", "set", "not_set"].includes(op.key)
).find((op) => op.symbol === symbol);
}
@@ -1,33 +0,0 @@
/** @odoo-module **/
import { DomainSelectorBranchNode } from "./domain_selector_branch_node";
import { DomainSelectorBranchOperator } from "./domain_selector_branch_operator";
import { DomainSelectorLeafNode } from "./domain_selector_leaf_node";
import { Component } from "@odoo/owl";
export class DomainSelectorRootNode extends Component {
get hasNode() {
return this.props.node.operands.length > 0;
}
get node() {
return this.props.node.operands[0];
}
insertNode(newNodeType) {
this.props.node.insert(newNodeType);
}
onOperatorSelected(ev) {
this.props.node.update(ev.detail.payload.operator);
}
onChange(ev) {
this.props.node.update(ev.target.value, true);
}
}
DomainSelectorRootNode.template = "web.DomainSelectorRootNode";
DomainSelectorRootNode.components = {
DomainSelectorBranchNode,
DomainSelectorBranchOperator,
DomainSelectorLeafNode,
};
@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorRootNode" owl="1">
<div
class="o_domain_node o_domain_tree o_domain_selector"
aria-atomic="true"
t-attf-class="{{ props.className }} {{ props.readonly ? 'o_read_mode' : 'o_edit_mode'}}"
>
<t t-if="!hasNode">
<span>Match <strong>all records</strong></span>
<t t-if="!props.readonly">
<button
class="btn btn-sm btn-primary o_domain_add_first_node_button ms-1"
t-on-click="() => this.insertNode('leaf')"
>
<i class="fa fa-plus"/> Add filter
</button>
</t>
</t>
<t t-else="">
<t t-if="node.type === 'leaf'">
Match records with the following rule:
<div class="o_domain_node_children_container">
<DomainSelectorLeafNode t-props="{ ...props, node }" />
</div>
</t>
<t t-else="">
<span>Match records with </span>
<DomainSelectorBranchOperator node="node" readonly="props.readonly" showCaret="true" />
<span> of the following rules:</span>
<div class="o_domain_node_children_container">
<t t-foreach="node.operands" t-as="subNode" t-key="subNode.id">
<t t-if="subNode.type === 'branch'">
<DomainSelectorBranchNode t-props="{ ...props, node: subNode }" />
</t>
<t t-elif="subNode.type === 'leaf'">
<DomainSelectorLeafNode t-props="{ ...props, node: subNode }" />
</t>
</t>
</div>
</t>
</t>
<t t-if="props.isDebugMode and !props.readonly">
<label class="o_domain_debug_container">
<span class="small"># Code editor</span>
<textarea type="text" class="o_domain_debug_input" t-att-value="props.debugValue or props.value" t-on-change="onChange" />
</label>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,102 @@
/** @odoo-module **/
import { BranchDomainNode, LeafDomainNode } from "@web/core/domain_selector/domain_selector_nodes";
import { findOperator, parseOperator } from "@web/core/domain_selector/domain_selector_operators";
export class DomainTreeBuilder {
build(domain, fieldDefs) {
const nodeIterator = domain.ast.value.values();
const nextNode = () => {
const it = nodeIterator.next();
return it.done ? null : it.value;
};
const rawNode = nextNode();
const node =
rawNode && this.isBranch(rawNode)
? new BranchDomainNode(this.getBranchOperator(rawNode))
: new BranchDomainNode("AND");
if (rawNode) {
this.buildNode(node, rawNode, nextNode, fieldDefs);
}
return node;
}
/** @private */
buildNode(parent, rawNode, nextNode, fieldDefs) {
if (this.isBranch(rawNode)) {
this.buildBranch(parent, rawNode, nextNode, fieldDefs);
} else {
this.buildLeaf(parent, rawNode, fieldDefs);
}
}
/** @private */
buildLeaf(parent, rawNode, fieldDefs) {
const field = this.getLeafFieldName(rawNode);
const operatorInfo = this.getLeafOperatorInfo(rawNode, fieldDefs[field].type);
const value = this.isLeafValueArray(rawNode)
? this.getLeafValue(rawNode).map((v) => v.value)
: this.getLeafValue(rawNode);
parent.add(
new LeafDomainNode({ ...fieldDefs[field], name: field.toString() }, operatorInfo, value)
);
}
/** @private */
buildBranch(parent, rawNode, nextNode, fieldDefs) {
let newParent = parent;
const currentOperator = this.getBranchOperator(rawNode);
if (currentOperator !== parent.operator) {
newParent = new BranchDomainNode(currentOperator);
parent.add(newParent);
}
this.buildNode(newParent, nextNode(), nextNode, fieldDefs);
this.buildNode(newParent, nextNode(), nextNode, fieldDefs);
}
/** @private */
isBranch(rawNode) {
return rawNode.type === 1 && ["&", "|", "!"].includes(rawNode.value);
}
/** @private */
getBranchOperator(rawNode) {
switch (rawNode.value) {
case "&":
return "AND";
case "|":
return "OR";
case "!":
return "NOT";
}
}
/** @private */
getLeafFieldName(rawNode) {
return rawNode.value[0].value;
}
/** @private */
getLeafOperatorInfo(rawNode, fieldType) {
const rawOperator = rawNode.value[1].value;
if (fieldType === "boolean") {
return findOperator(rawOperator === "=" ? "is" : "is_not");
} else if (rawNode.value[2].type === 2) {
return findOperator(rawOperator === "!=" ? "set" : "not_set");
} else {
return parseOperator(rawOperator);
}
}
/** @private */
getLeafValue(rawNode) {
return rawNode.value[2].value;
}
/** @private */
isLeafValueArray(rawNode) {
return [4, 10].includes(rawNode.value[2].type);
}
}
@@ -1,47 +0,0 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
const dsf = registry.category("domain_selector/fields");
export class DomainSelectorBooleanField extends Component {
onChange(ev) {
this.props.update({
value: ev.target.value === "1",
});
}
}
Object.assign(DomainSelectorBooleanField, {
template: "web.DomainSelectorBooleanField",
onDidTypeChange() {
return { value: true };
},
getOperators() {
return [
{
category: "equality",
label: _lt("is"),
value: "=",
onDidChange() {},
matches({ operator }) {
return operator === this.value;
},
},
{
category: "equality",
label: _lt("is not"),
value: "!=",
onDidChange() {},
matches({ operator }) {
return operator === this.value;
},
},
];
},
});
dsf.add("boolean", DomainSelectorBooleanField);
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorBooleanField" owl="1">
<div class="o_ds_value_cell">
<select class="o_input" t-on-change="onChange">
<option value="1" t-att-selected="props.value">set (true)</option>
<option value="0" t-att-selected="!props.value">not set (false)</option>
</select>
</div>
</t>
</templates>
@@ -1,52 +0,0 @@
/** @odoo-module **/
import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker";
import {
deserializeDate,
deserializeDateTime,
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
const dsf = registry.category("domain_selector/fields");
const dso = registry.category("domain_selector/operator");
export class DomainSelectorDateTimeField extends Component {
get component() {
const { DatePicker, DateTimePicker } = this.constructor.components;
return this.props.field.type === "date" ? DatePicker : DateTimePicker;
}
get deserializedValue() {
const deserialize =
this.props.field.type === "date" ? deserializeDate : deserializeDateTime;
return this.props.value ? deserialize(this.props.value) : luxon.DateTime.local();
}
onChange(value) {
if (!this.deserializedValue.isValid && !value) {
return;
}
const serialize = this.props.field.type === "date" ? serializeDate : serializeDateTime;
this.props.update({ value: serialize(value || luxon.DateTime.local()) });
}
}
Object.assign(DomainSelectorDateTimeField, {
template: "web.DomainSelectorDateTimeField",
components: {
DatePicker,
DateTimePicker,
},
onDidTypeChange(field) {
const serialize = field.type === "date" ? serializeDate : serializeDateTime;
return { value: serialize(luxon.DateTime.local()) };
},
getOperators() {
return ["=", "!=", ">", "<", ">=", "<=", "set", "not set"].map((key) => dso.get(key));
},
});
dsf.add("date", DomainSelectorDateTimeField);
dsf.add("datetime", DomainSelectorDateTimeField);
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorDateTimeField" owl="1">
<div class="o_ds_value_cell">
<t t-component="component" date="deserializedValue" onDateTimeChanged.bind="onChange" />
</div>
</t>
</templates>
@@ -1,23 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { DomainSelectorFieldInput } from "./domain_selector_field_input";
import { Component } from "@odoo/owl";
const dso = registry.category("domain_selector/operator");
export class DomainSelectorDefaultField extends Component {}
Object.assign(DomainSelectorDefaultField, {
template: "web.DomainSelectorDefaultField",
components: {
DomainSelectorFieldInput,
},
onDidTypeChange() {
return { value: "" };
},
getOperators() {
return dso.getAll();
},
});
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorDefaultField" owl="1">
<div class="o_ds_value_cell">
<DomainSelectorFieldInput t-props="props" />
</div>
</t>
</templates>
@@ -1,22 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
const parsers = registry.category("parsers");
export class DomainSelectorFieldInput extends Component {
parseValue(value) {
const parser = parsers.get(this.props.field.type, (value) => value);
try {
return parser(value);
} catch {
return value;
}
}
onChange(ev) {
this.props.update({ value: this.parseValue(ev.target.value) });
}
}
DomainSelectorFieldInput.template = "web.DomainSelectorFieldInput";
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorFieldInput" owl="1">
<input type="text" class="o_input o_domain_leaf_value_input" t-att-value="props.value" t-on-change="onChange" />
</t>
</templates>
@@ -1,25 +0,0 @@
/** @odoo-module **/
import { Component, useRef } from "@odoo/owl";
export class DomainSelectorFieldInputWithTags extends Component {
setup() {
this.inputRef = useRef("input");
}
removeTag(tagIndex) {
const value = [...this.props.value];
value.splice(tagIndex, 1);
this.props.update({ value });
}
addTag(value) {
this.props.update({ value: this.props.value.concat(value) });
}
onBtnClick() {
const value = this.inputRef.el.value;
this.inputRef.el.value = "";
this.addTag(value);
}
}
DomainSelectorFieldInputWithTags.template = "web.DomainSelectorFieldInputWithTags";
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorFieldInputWithTags" owl="1">
<div class="o_ds_value_cell">
<div class="o_domain_leaf_value_input">
<t t-foreach="props.value" t-as="tag" t-key="tag_index">
<span class="badge rounded-pill">
<t t-esc="tag" /> <i
class="o_domain_leaf_value_remove_tag_button fa fa-times"
role="img"
aria-label="Remove tag"
title="Remove tag"
t-on-click="() => this.removeTag(tag_index)"
/>
</span>
</t>
</div>
<div class="o_domain_leaf_value_tags">
<input type="text" class="o_input" placeholder="Add new value" t-ref="input" />
<button
class="btn btn-sm btn-primary fa fa-plus o_domain_leaf_value_add_tag_button"
aria-label="Add tag"
title="Add tag"
t-on-click="onBtnClick"
/>
</div>
</div>
</t>
</templates>
@@ -1,39 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { DomainSelectorFieldInput } from "./domain_selector_field_input";
import { Component } from "@odoo/owl";
const dsf = registry.category("domain_selector/fields");
const dso = registry.category("domain_selector/operator");
export class DomainSelectorNumberField extends Component {}
Object.assign(DomainSelectorNumberField, {
template: "web.DomainSelectorNumberField",
components: {
DomainSelectorFieldInput,
},
onDidTypeChange() {
return { value: 0 };
},
getOperators() {
return [
"=",
"!=",
">",
"<",
">=",
"<=",
"ilike",
"not ilike",
"set",
"not set",
].map((key) => dso.get(key));
},
});
dsf.add("integer", DomainSelectorNumberField);
dsf.add("float", DomainSelectorNumberField);
dsf.add("monetary", DomainSelectorNumberField);
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorNumberField" owl="1">
<div class="o_ds_value_cell">
<DomainSelectorFieldInput t-props="props" />
</div>
</t>
</templates>
@@ -1,29 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { DomainSelectorFieldInput } from "./domain_selector_field_input";
import { Component } from "@odoo/owl";
const dso = registry.category("domain_selector/operator");
export class DomainSelectorRelationField extends Component {}
Object.assign(DomainSelectorRelationField, {
template: "web.DomainSelectorRelationField",
components: {
DomainSelectorFieldInput,
},
onDidTypeChange() {
return { value: "0" };
},
getOperators() {
return ["=", "!=", "ilike", "not ilike", "set", "not set"].map((key) => dso.get(key));
},
});
registry
.category("domain_selector/fields")
.add("one2many", DomainSelectorRelationField)
.add("many2one", DomainSelectorRelationField)
.add("many2many", DomainSelectorRelationField);
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorRelationField" owl="1">
<div class="o_ds_value_cell">
<DomainSelectorFieldInput t-props="props" />
</div>
</t>
</templates>
@@ -1,44 +0,0 @@
/** @odoo-module **/
import { evaluateExpr } from "@web/core/py_js/py";
import { formatAST, toPyValue } from "@web/core/py_js/py_utils";
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
const dsf = registry.category("domain_selector/fields");
const dso = registry.category("domain_selector/operator");
export class DomainSelectorSelectionField extends Component {
get options() {
return [
[false, ""],
...this.props.field.selection.filter((option) => !this.props.value.includes(option[0])),
];
}
get formattedValue() {
const ast = toPyValue(this.props.value);
return formatAST(ast);
}
onChange(ev) {
this.props.update({ value: ev.target.value });
}
onChangeMulti(ev) {
this.props.update({ value: evaluateExpr(ev.target.value) });
}
}
Object.assign(DomainSelectorSelectionField, {
template: "web.DomainSelectorSelectionField",
onDidTypeChange(field) {
return { value: field.selection[0][0] };
},
getOperators() {
return ["=", "!=", "set", "not set"].map((key) => dso.get(key));
},
});
dsf.add("selection", DomainSelectorSelectionField);
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorSelectionField" owl="1">
<div class="o_ds_value_cell">
<t t-if="props.operator.category === 'in'">
<input type="text" class="o_input o_domain_leaf_value_input" t-att-value="formattedValue" t-on-change="onChangeMulti" />
</t>
<t t-else="">
<select class="o_input o_domain_leaf_value_input" t-on-change="onChange">
<t t-foreach="props.field.selection" t-as="option" t-key="option[0]">
<option
t-att-value="option[0]"
t-att-selected="option[0] === props.value"
t-esc="option[1]"
/>
</t>
</select>
</t>
</div>
</t>
</templates>
@@ -1,32 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { DomainSelectorFieldInput } from "./domain_selector_field_input";
import { DomainSelectorFieldInputWithTags } from "./domain_selector_field_input_with_tags";
import { Component } from "@odoo/owl";
const dsf = registry.category("domain_selector/fields");
const dso = registry.category("domain_selector/operator");
export class DomainSelectorTextField extends Component {}
Object.assign(DomainSelectorTextField, {
template: "web.DomainSelectorTextField",
components: {
DomainSelectorFieldInput,
DomainSelectorFieldInputWithTags,
},
onDidTypeChange() {
return { value: "" };
},
getOperators() {
return ["=", "!=", "ilike", "not ilike", "set", "not set", "in", "not in"].map((key) =>
dso.get(key)
);
},
});
dsf.add("char", DomainSelectorTextField);
dsf.add("html", DomainSelectorTextField);
dsf.add("text", DomainSelectorTextField);
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorTextField" owl="1">
<t t-if="props.operator.category === 'in'">
<DomainSelectorFieldInputWithTags t-props="props" />
</t>
<t t-else="">
<div class="o_ds_value_cell">
<DomainSelectorFieldInput t-props="props" />
</div>
</t>
</t>
</templates>
@@ -6,17 +6,7 @@ export function useModelField() {
const view = useService("view");
const loadModelFields = (resModel) => {
return view.loadFields(resModel, {
attributes: [
"store",
"searchable",
"type",
"string",
"relation",
"selection",
"related",
],
});
return view.loadFields(resModel);
};
const loadChain = async (resModel, fieldName) => {
@@ -4,24 +4,30 @@ import { useModelField } from "./model_field_hook";
import { useUniquePopover } from "./unique_popover_hook";
import { ModelFieldSelectorPopover } from "./model_field_selector_popover";
import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl";
import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
export class ModelFieldSelector extends Component {
setup() {
this.popover = useUniquePopover();
this.modelField = useModelField();
this.chain = [];
this.state = useState({
chain: [],
fieldName: this.props.fieldName,
isDirty: false,
});
onWillStart(async () => {
this.chain = await this.loadChain(this.props.resModel, this.props.fieldName);
this.state.chain = await this.loadChain(this.props.resModel, this.props.fieldName);
});
onWillUpdateProps(async (nextProps) => {
this.chain = await this.loadChain(nextProps.resModel, nextProps.fieldName);
this.state.chain = await this.loadChain(nextProps.resModel, nextProps.fieldName);
this.state.fieldName = nextProps.fieldName;
this.state.isDirty = false;
});
}
get fieldNameChain() {
return this.getFieldNameChain(this.props.fieldName);
return this.getFieldNameChain(this.state.fieldName ?? this.props.fieldName);
}
getFieldNameChain(fieldName) {
@@ -32,29 +38,16 @@ export class ModelFieldSelector extends Component {
if ("01".includes(fieldName)) {
return [{ resModel, field: { string: fieldName } }];
}
const fieldNameChain = this.getFieldNameChain(fieldName);
let currentNode = {
resModel,
field: null,
};
const chain = [currentNode];
for (const fieldName of fieldNameChain) {
const fieldsInfo = await this.modelField.loadModelFields(currentNode.resModel);
Object.assign(currentNode, {
field: { ...fieldsInfo[fieldName], name: fieldName },
});
if (fieldsInfo[fieldName].relation) {
currentNode = {
resModel: fieldsInfo[fieldName].relation,
field: null,
};
chain.push(currentNode);
}
}
return chain;
return this.modelField.loadChain(resModel, fieldName);
}
update(chain) {
this.props.update(chain.join("."));
async update(fieldName, isFieldSelected) {
this.state.fieldName = fieldName;
this.state.isDirty = !isFieldSelected;
if (isFieldSelected) {
await this.props.update(fieldName);
} else {
this.state.chain = await this.loadChain(this.props.resModel, fieldName);
}
}
onFieldSelectorClick(ev) {
@@ -65,7 +58,7 @@ export class ModelFieldSelector extends Component {
ev.currentTarget,
this.constructor.components.Popover,
{
chain: this.chain,
chain: this.state.chain,
update: this.update.bind(this),
showSearchInput: this.props.showSearchInput,
isDebugMode: this.props.isDebugMode,
@@ -76,6 +69,11 @@ export class ModelFieldSelector extends Component {
{
closeOnClickAway: true,
popoverClass: "o_popover_field_selector",
onClose: () => {
if (this.state.isDirty) {
this.props.update(this.state.fieldName);
}
},
}
);
}
@@ -9,7 +9,7 @@
t-on-click="onFieldSelectorClick"
>
<div class="o_field_selector_value" tabindex="0">
<t t-foreach="chain" t-as="node" t-key="node_index">
<t t-foreach="state.chain" t-as="node" t-key="node_index">
<t t-if="node.field">
<t t-if="!node_first">
<i class="fa fa-chevron-right" role="img" aria-label="Followed by" title="Followed-by" />
@@ -62,12 +62,16 @@ export class ModelFieldSelectorPopover extends Component {
const keys = Object.keys(obj);
return sortBy(keys, (key) => obj[key].string);
}
async update() {
const fieldNameChain = this.fieldNameChain;
this.fullFieldName = fieldNameChain.join(".");
await this.props.update(fieldNameChain);
async update(isSelected) {
const fieldNameChain = this.fieldNameChain.join(".");
this.fullFieldName = fieldNameChain;
await this.loadFields();
this.render();
await this.props.update(fieldNameChain, isSelected);
if (isSelected) {
this.props.close();
} else {
this.render();
}
}
async onInputKeydown(ev) {
@@ -147,11 +151,9 @@ export class ModelFieldSelectorPopover extends Component {
this.update();
} else if (this.props.needDefaultValue) {
this.isDefaultValueVisible = true;
this.render();
this.update();
} else {
this.update();
this.props.close();
this.update(true);
this.props.validate(this.fieldNameChain, this.defaultValue);
}
}
@@ -159,8 +161,7 @@ export class ModelFieldSelectorPopover extends Component {
if (!acceptDefaultValue) {
this.defaultValue = "";
}
this.props.close();
this.update();
this.update(true);
this.props.validate(this.fieldNameChain, this.defaultValue);
}
async onFieldNameChange(ev) {
@@ -172,7 +173,7 @@ export class ModelFieldSelectorPopover extends Component {
} catch {
// WOWL TODO: rethrow error when not the expected type
this.chain = [{ resModel, field: null }];
await this.props.update([]);
await this.props.update("");
this.render();
}
}
@@ -1,18 +1,19 @@
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import { DomainSelector } from "@web/core/domain_selector/domain_selector";
import { OPERATOR_DESCRIPTIONS } from "@web/core/domain_selector/domain_selector_operators";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { ormService } from "@web/core/orm_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { viewService } from "@web/views/view_service";
import { registerCleanup } from "../helpers/cleanup";
import { makeTestEnv } from "../helpers/mock_env";
import { click, editInput, editSelect, getFixture, mount, triggerEvent } from "../helpers/utils";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import { Component, xml } from "@odoo/owl";
import { click, editInput, editSelect, getFixture, mount, triggerEvent } from "../helpers/utils";
let serverData;
let target;
@@ -433,7 +434,10 @@ QUnit.module("Components", (hooks) => {
);
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "1");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "0"); // option "="
assert.strictEqual(
target.querySelector(".o_domain_leaf_operator_select").value,
"equal"
); // option "="
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "1");
newValue = `[(0, "=", 1)]`;
@@ -452,7 +456,10 @@ QUnit.module("Components", (hooks) => {
);
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "0");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "0"); // option "="
assert.strictEqual(
target.querySelector(".o_domain_leaf_operator_select").value,
"equal"
); // option "="
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "1");
}
);
@@ -469,13 +476,14 @@ QUnit.module("Components", (hooks) => {
});
QUnit.test("operator fallback in edit mode", async (assert) => {
registry.category("domain_selector/operator").add("test", {
category: "test",
OPERATOR_DESCRIPTIONS.push({
key: "test",
label: "test",
value: "test",
onDidChange: () => null,
matches: ({ operator }) => operator === "test",
hideValue: true,
symbol: "test",
valueMode: "none",
});
registerCleanup(() => {
OPERATOR_DESCRIPTIONS.pop();
});
await mountComponent(DomainSelector, {
@@ -542,13 +550,16 @@ QUnit.module("Components", (hooks) => {
await mountComponent(Parent);
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "State");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "2"); // option "!="
assert.strictEqual(
target.querySelector(".o_domain_leaf_operator_select").value,
"not_equal"
); // option "!="
await editSelect(target, ".o_domain_leaf_operator_select", 0);
await editSelect(target, ".o_domain_leaf_operator_select", "equal");
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "State");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "0"); // option "="
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "abc");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "equal"); // option "="
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, `"abc"`);
});
QUnit.test("show correct operator", async (assert) => {
@@ -79,6 +79,7 @@ QUnit.module("Components", (hooks) => {
this.fieldName = "";
}
onUpdate(value) {
assert.step(`update: ${value}`);
this.fieldName = value;
this.render();
}
@@ -136,6 +137,7 @@ QUnit.module("Components", (hooks) => {
"bar",
"the selected field should be correctly set"
);
assert.verifySteps(["update: bar"]);
// Focusing the input again should open the same popover
await click(target, ".o_field_selector");
@@ -182,12 +184,14 @@ QUnit.module("Components", (hooks) => {
"Product -> Product Name",
"field selector value should be displayed with two tags: 'Product' and 'Product Name'"
);
assert.verifySteps(["update: product_id.name"]);
// Remove the current selection and recreate it again
await click(target, ".o_field_selector");
await click(target, ".o_field_selector_prev_page");
await click(target, ".o_field_selector_prev_page");
await click(target, ".o_field_selector_close");
assert.verifySteps(["update: "]);
await click(target, ".o_field_selector");
assert.containsOnce(
@@ -210,6 +214,7 @@ QUnit.module("Components", (hooks) => {
"Product -> Product Name",
"field selector value should be displayed with two tags: 'Product' and 'Product Name'"
);
assert.verifySteps(["update: product_id.name"]);
});
QUnit.test("default field chain should set the page data correctly", async (assert) => {
@@ -812,7 +812,7 @@ QUnit.module("Fields", (hooks) => {
assert.verifySteps(["/web/webclient/load_menus"]);
await doAction(webClient, 1);
assert.verifySteps(["/web/action/load", "get_views", "read", "search_count", "fields_get"]);
assert.verifySteps(["/web/action/load", "get_views", "read", "search_count"]);
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain);
assert.containsOnce(target, ".o_datepicker", "there should be a datepicker");