[IMP] *: improve domain selector edition, dom, and style

We improve various aspects of the domain selector:

- We make the domain selector be able to display all domains accepted by the class Domain (domain.js)
- We improve the edition/selection of values, e.g. for the operators in/not in
- We improve/simplify the style/dom

Task IDs: 3507123, 3508940

closes odoo/odoo#128680

Related: odoo/enterprise#48324
Signed-off-by: Michaël Mattiello (mcm) <mcm@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Mathieu Notté <mano@odoo.com>
This commit is contained in:
Mathieu Duckerts-Antoine
2023-10-13 06:27:42 +00:00
co-authored by Mathieu Notté
parent d9ed827323
commit 6428aef678
27 changed files with 1788 additions and 1842 deletions
@@ -23,6 +23,7 @@ import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import testUtils from "@web/../tests/legacy/helpers/test_utils";
import { makeFakeUserService } from "@web/../tests/helpers/mock_services";
import * as dsHelpers from "@web/../tests/core/domain_selector_tests";
const patchDate = testUtils.mock.patchDate;
const favoriteMenuRegistry = registry.category("favoriteMenu");
@@ -495,7 +496,7 @@ QUnit.module("Board", (hooks) => {
// Add a filter ['foo', '=', 'yop']
await toggleSearchBarMenu(target);
await openAddCustomFilterDialog(target);
await editInput(target, ".o_domain_debug_input", `[("foo", "=", "yop")]`);
await editInput(target, dsHelpers.SELECTORS.debugArea, `[("foo", "=", "yop")]`);
await click(target.querySelector(".modal footer button"));
// Add to dashboard
@@ -3,6 +3,7 @@
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import * as testUtils from "@web/../tests/helpers/utils";
import weTestUtils from "@web_editor/../tests/test_utils";
import * as dsHelpers from "@web/../tests/core/domain_selector_tests";
let fixture;
let serverData;
@@ -377,7 +378,7 @@ QUnit.module('favorite filter widget', (hooks) => {
"should show icon to save the filter because domain is set in the mailing");
// If domain is not set on mailing and no filter available, both drop-down and icon container are hidden
await testUtils.click(fixture.querySelector('.o_domain_delete_node_button'));
await dsHelpers.clickOnButtonDeleteNode(fixture);
assert.isNotVisible(fixture.querySelector('.o_field_mailing_filter .o_input_dropdown'),
"should not display drop-down because there is still no filter available to select from");
assert.isNotVisible(fixture.querySelector('.o_mass_mailing_filter_container'),
@@ -400,7 +401,7 @@ QUnit.module('favorite filter widget', (hooks) => {
assert.isNotVisible(fixture.querySelector('.o_mass_mailing_save_filter_container'),
"should not have option to save filter if filter is selected");
await testUtils.click(fixture.querySelector('.o_domain_add_node_button'));
await dsHelpers.clickOnButtonAddNewRule(fixture);
await testUtils.nextTick();
assert.isVisible(fixture.querySelector('.o_mass_mailing_save_filter_container'),
"should have option to save filter because mailing domain is changed");
@@ -28,7 +28,7 @@
.o_input {
color: #3c3c3c !important;
}
.o_domain_leaf_info {
.o_read_mode .o_domain_selector_condition {
background: #f6f7fa !important;
border-color: #d5dae8 !important;
}
@@ -15,15 +15,15 @@ import { Domain } from "@web/core/domain";
import { CheckBox } from "@web/core/checkbox/checkbox";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { getOperatorInfo, toOperator } from "@web/core/domain_selector/domain_selector_operators";
import { getPathEditorInfo } from "@web/core/domain_selector/domain_selector_path_editor";
import {
Editor,
PathEditor,
getDefaultOperator,
getOperatorEditorInfo,
} from "@web/core/domain_selector/domain_selector_operator_editor";
import {
getDefaultValue,
getOperatorsInfo,
getEditorInfo,
} from "@web/core/domain_selector/domain_selector_fields";
getValueEditorInfo,
} from "@web/core/domain_selector/domain_selector_value_editors";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import { useLoadFieldInfo } from "@web/core/model_field_selector/utils";
import { formatValue } from "@web/core/domain_tree";
@@ -88,13 +88,11 @@ function restoreVirtualOperators(tree, otherTree) {
}
export class DomainSelector extends Component {
static template = "web._DomainSelector";
static template = "web.DomainSelector";
static components = {
Dropdown,
DropdownItem,
ModelFieldSelector,
Editor,
PathEditor,
CheckBox,
};
static props = {
@@ -230,7 +228,7 @@ export class DomainSelector extends Component {
}
notifyChanges() {
this.previousTree = cloneTree(this.tree);
this.previousTree = this.tree ? cloneTree(this.tree) : null;
const archiveDomain = this.includeArchived ? `[("active", "in", [True, False])]` : `[]`;
const domain = this.tree
? Domain.and([buildDomain(this.tree), archiveDomain]).toString()
@@ -304,10 +302,28 @@ export class DomainSelector extends Component {
}
getDescription(node) {
const { path, value } = node;
const fieldDef = this.getFieldDef(path);
const operatorInfo = this.getOperatorInfo(node);
return leafToString(fieldDef, operatorInfo, value, this.displayNames[fieldDef?.relation]);
const fieldDef = this.getFieldDef(node.path);
return leafToString(
node,
fieldDef,
this.displayNames[fieldDef?.relation || fieldDef?.comodel]
);
}
getPathEditorInfo() {
const { resModel, isDebugMode } = this.props;
const defaultPath = this.defaultCondition.path;
return getPathEditorInfo({ defaultPath, isDebugMode, resModel });
}
getOperatorEditorInfo(node) {
const fieldDef = this.getFieldDef(node.path);
return getOperatorEditorInfo(fieldDef);
}
getValueEditorInfo(node) {
const fieldDef = this.getFieldDef(node.path);
return getValueEditorInfo(fieldDef, node.operator);
}
resetDomain() {
@@ -321,69 +337,27 @@ export class DomainSelector extends Component {
this.notifyChanges();
}
updatePath(node, path, { fieldDef } = {}) {
if (!path) {
// don't like that
Object.assign(node, this.createNewLeaf());
} else {
node.path = path;
node.operator = getDefaultOperator(fieldDef);
node.value = getDefaultValue(fieldDef, node.operator);
}
async updatePath(node, path) {
const { fieldDef } = await this.loadFieldInfo(this.props.resModel, path);
node.path = path;
node.negate = false;
node.operator = getDefaultOperator(fieldDef);
node.value = getDefaultValue(fieldDef, node.operator);
this.notifyChanges();
}
updateLeafOperator(node, operatorKey) {
const previousOperatorInfo = getOperatorInfo(node.operator);
const previousNode = node ? cloneTree(node) : null;
const [operator, negate] = toOperator(operatorKey);
updateLeafOperator(node, operator, negate) {
const previousNode = cloneTree(node);
const fieldDef = this.getFieldDef(node.path);
node.negate = negate;
node.operator = operator;
const editorInfo = this.getEditorInfo(node);
if (!editorInfo.isSupported(node.value) || editorInfo.shouldResetValue?.(node.value)) {
node.value = getDefaultValue(this.getFieldDef(node.path), node.operator);
}
const operatorInfo = getOperatorInfo(operator);
if (previousOperatorInfo.valueCount !== operatorInfo.valueCount) {
switch (operatorInfo.valueCount) {
// binary operator with a variable sized array value
case "variable": {
node.value = [];
break;
}
// unary operator (set | not set)
case 0: {
node.value = false;
break;
}
// binary operator with a non array value
case 1: {
node.value = getDefaultValue(this.getFieldDef(node.path), node.operator);
break;
}
// binary operator with a fixed sized array value
default: {
const defaultValue = getDefaultValue(
this.getFieldDef(node.path),
node.operator
);
node.value = Array(operatorInfo.valueCount).fill(defaultValue);
break;
}
}
}
if (buildDomain(node) === buildDomain(previousNode)) {
node.value = getDefaultValue(fieldDef, operator, node.value);
if (buildDomain(previousNode) === buildDomain(node)) {
// no interesting changes for parent (only possible domain formatting/rewriting)
// this means that parent might not render the domain selector
// but we need to udpate editors
this.render();
}
this.notifyChanges();
}
@@ -396,30 +370,8 @@ export class DomainSelector extends Component {
return this.props.update(value, true);
}
getEditorInfo(node) {
return getEditorInfo(this.getFieldDef(node.path), node.operator);
}
getOperatorInfo(node) {
return getOperatorInfo(node.operator, node.negate);
}
getOperatorsInfo(node) {
const fieldDef = this.getFieldDef(node.path);
const operatorsInfo = getOperatorsInfo(fieldDef);
if (
!operatorsInfo.some((op) => op.operator === node.operator && op.negate === node.negate)
) {
const operatorInfo = this.getOperatorInfo(node);
operatorsInfo.push(operatorInfo);
}
return operatorsInfo;
}
highlightNode(target, toggle, classNames) {
const nodeEl = target.closest(".o_domain_node");
for (const className of classNames.split(/\s+/i)) {
nodeEl.classList.toggle(className, toggle);
}
highlightNode(target, toggle) {
const nodeEl = target.closest(".o_domain_selector_node");
nodeEl.classList.toggle("o_hovered_button", toggle);
}
}
@@ -1,175 +1,15 @@
.o_domain_selector:not(.o_domain_selector_legacy) {
cursor: default;
&.o_domain_node, .o_domain_node {
$o-domain-selector-indent: 32px;
$o-domain-selector-panel-space: 60px;
$o-domain-selector-row-height: 35px; // should be greater than a "normal" row so that "header" parts are the same size of rows
$o-domain-animation-bar-height: 15px;
position: relative;
// Rows in a domain node (selector) have a minimum height and holds the control panel
.o_domain_selector_row {
display: flex;
align-items: center;
min-height: $o-domain-selector-row-height;
> * {
flex: 0 0 auto;
}
> .o_domain_node_control_panel {
@include o-position-absolute($top: 0, $bottom: 0, $right: -$o-domain-selector-panel-space);
> button {
float: left;
width: $o-domain-selector-panel-space/2;
height: 100%;
padding: 0;
background-color: transparent;
font-size: 16px;
opacity: 0.2;
cursor: pointer;
&.o_domain_delete_node_button {
font-size: 12px;
padding-left: 5px;
}
}
}
.o_domain_selector {
.o_domain_selector_node {
.o_domain_selector_node_control_panel > button {
opacity: .2;
}
// Rules specific to domain parts which contain sub-domain parts
&.o_domain_tree {
> .o_domain_tree_header {
position: relative;
}
.o_domain_tree_connector_caret::after {
@include o-caret-down;
}
&.o_hovered_button {
.o_domain_selector_node_control_panel > button {
opacity: .5;
& .o_domain_node_children_container {
padding-left: $o-domain-selector-indent;
}
// Rules specific to domain root
&.o_domain_selector {
> .o_domain_tree_header {
min-height: 22px; // prevents "records button" to overflow 1-row domain
}
> .o_domain_node_children_container {
padding-left: 0;
}
&.o_edit_mode > .o_domain_node_children_container {
padding-right: $o-domain-selector-panel-space; // delete button is over the row
}
> .o_domain_debug_container {
display: block;
margin-top: 16px;
padding: 8px 10px 12px;
background: var(--DomainDebugContainer-background-colomap-get, #{$o-gray-900});
color: rgba(white, 0.5);
font-family: monospace;
font-weight: normal;
> textarea {
border: none;
padding-top: 8px;
background: transparent;
color: white;
}
}
}
}
// Rules specific to domain parts which do not contain sub-domain parts
&.o_domain_leaf {
&.o_read_mode {
display: inline-flex;
margin-right: 4px;
}
> .o_domain_leaf_info {
background: $o-brand-lightsecondary;
border: 1px solid darken($o-brand-lightsecondary, 10%);
padding: 2px 4px;
> .o_model_field_selector {
float: left;
margin-right: 4px;
> .o_model_field_selector_value > .o_model_field_selector_chain_part {
border: none;
font-weight: 700;
}
}
.o_domain_leaf_operator {
font-style: italic;
}
.o_domain_leaf_value {
font-weight: 700;
}
}
> .o_domain_leaf_edition {
display: flex;
width: 100%;
padding: 4px ($o-domain-selector-panel-space/2) 4px 0;
> * {
width: auto;
&.o_model_field_selector {
flex: 0 1 40%;
width: 40%;
> .o_model_field_selector_value > .o_model_field_selector_chain_part {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&:last-child { // field value
flex: 1 1 25%;
}
+ * {
margin-left: 4px;
}
input, select, .o_datepicker, .o_datepicker_input {
height: 100%;
}
}
}
}
// Animation effects
transition: margin-bottom .05s ease .05s;
&::after {
content: "";
@include o-position-absolute($left: 0, $right: 0, $top: 100%);
display: block;
height: $o-domain-animation-bar-height;
max-height: 0;
background-image: linear-gradient(45deg, rgba($o-brand-primary, 0.4), rgba($o-brand-primary, 0.1));
transition: max-height .05s ease .05s;
}
&.o_hover_btns, &.o_hover_add_node {
.o_domain_node_control_panel > button {
opacity: 0.5;
&:hover {
opacity: 1.0;
&.o_domain_delete_node_button {
color: var(--danger);
}
opacity: 1;
}
}
}
@@ -1,100 +1,103 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web._DomainSelector">
<div class="o_domain_node o_domain_tree o_domain_selector w-100" aria-atomic="true" t-att-class="className" t-ref="root">
<t t-name="web.DomainSelector">
<div class="o_domain_selector w-100" aria-atomic="true" t-att-class="className">
<t t-if="tree">
<t t-set="node" t-value="tree"/>
<div class="d-flex justify-content-between">
<div>
<t t-if="node.children.length === 0">
<span>Match <strong>all records</strong></span>
</t>
<t t-else="">
<span>Match </span>
<t t-call="web._DomainSelector.connector.dropdown" />
<span> of the following rules:</span>
</t>
<div t-attf-class="o_domain_selector_node d-flex flex-column #{props.readonly ? 'gap-2' : 'gap-1'}">
<t t-set="node" t-value="tree" />
<div class="o_domain_selector_row d-flex align-items-center">
<div class="o_domain_selector_connector d-flex flex-grow-1 align-items-center">
<t t-if="node.children.length">
<span>Match</span>
<t t-if="node.children.length > 1">
<t t-call="web.DomainSelector.connector.dropdown" />
</t>
<t t-else="">
<span class="px-1">
<t t-call="web.DomainSelector.connector.title" />
</span>
</t>
<span>of the following rules:</span>
</t>
<t t-else="">
<span>Match <strong>all records</strong></span>
</t>
</div>
<CheckBox
t-if="showArchivedCheckbox"
value="includeArchived"
disabled="props.readonly"
className="'form-switch'"
onChange.bind="toggleIncludeArchived"
>
Include archived
</CheckBox>
</div>
<CheckBox
t-if="showArchivedCheckbox"
value="includeArchived"
disabled="props.readonly"
className="'form-switch'"
onChange.bind="toggleIncludeArchived"
>
Include archived
</CheckBox>
<t t-if="node.children.length" t-call="web.DomainSelector.connector.children" />
<t t-if="!props.readonly">
<div class="o_domain_selector_row d-flex align-items-center">
<a href="#" role="button" t-on-click="() => this.insertRootLeaf(node)">New Rule</a>
</div>
</t>
</div>
<t t-if="node.children.length" t-call="web._DomainSelector.connector.children" />
<t t-if="!props.readonly">
<a href="#" class="pt-1 ms-1" role="button" t-on-click="() => this.insertRootLeaf(node)">New Rule</a>
</t>
</t>
<t t-else="">
This domain is not supported.
<t t-if="!props.readonly">
<button class="btn btn-sm btn-primary o_reset_domain_button ms-1" t-on-click="() => this.resetDomain()">Reset domain</button>
</t>
<div class="o_domain_selector_row d-flex align-items-center">
This domain is not supported.
<t t-if="!props.readonly">
<button t-on-click="() => this.resetDomain()">Reset domain</button>
</t>
</div>
</t>
<t t-if="props.isDebugMode and (!tree or !props.readonly)">
<label class="o_domain_debug_container">
<label class="o_domain_selector_debug_container d-block mt-3 border rounded p-3 bg-100 text-muted font-monospace">
<span class="small"># Code editor</span>
<textarea type="text" class="o_domain_debug_input" t-att-readonly="props.readonly" spellcheck="false" t-att-value="props.domain" t-on-change="(ev) => this.onDebugValueChange(ev.target.value)" />
<textarea class="pt-2 border-0 bg-transparent text-body" type="text" t-att-readonly="props.readonly" spellcheck="false" t-att-value="props.domain" t-on-change="(ev) => this.onDebugValueChange(ev.target.value)" />
</label>
</t>
</div>
</t>
<t t-name="web._DomainSelector.controls">
<div class="o_domain_node_control_panel" role="toolbar" aria-label="Domain node">
<t t-name="web.DomainSelector.controls">
<div class="o_domain_selector_node_control_panel d-flex" role="toolbar" aria-label="Domain node">
<button
class="btn o_domain_add_node_button"
class="btn px-2 fs-4"
role="button"
title="Add New Rule"
aria-label="Add New Rule"
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')"
t-on-mouseenter="(ev) => this.highlightNode(ev.target, true)"
t-on-mouseleave="(ev) => this.highlightNode(ev.target, false)"
>
<i class="fa fa-plus"/>
<i class="fa fa-plus" />
</button>
<button
class="btn o_domain_add_node_button"
class="btn px-2 fs-4"
role="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')"
t-on-mouseenter="(ev) => this.highlightNode(ev.target, true)"
t-on-mouseleave="(ev) => this.highlightNode(ev.target, false)"
>
<i class="fa fa-sitemap"/>
<i class="fa fa-sitemap" />
</button>
<button
class="btn btn-link text-danger o_domain_delete_node_button"
class="btn btn-link px-2 text-danger fs-4"
role="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')"
t-on-mouseenter="(ev) => this.highlightNode(ev.target, true)"
t-on-mouseleave="(ev) => this.highlightNode(ev.target, false)"
>
<i class="fa fa-trash"/>
<i class="fa fa-trash" />
</button>
</div>
</t>
<t t-name="web._DomainSelector.connector">
<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.connector.dropdown" />
<span class="ml4">of:</span>
</div>
<t t-call="web._DomainSelector.connector.children" />
</div>
</t>
<t t-name="web._DomainSelector.connector.dropdown">
<t t-name="web.DomainSelector.connector.title">
<t t-set="title">
<t t-if="node.value === '|'">
<t t-if="node.negate">none</t>
@@ -105,16 +108,20 @@
<t t-else="">all</t>
</t>
</t>
<t t-esc="title" />
</t>
<t t-name="web.DomainSelector.connector.dropdown">
<t t-if="props.readonly">
<strong>
<t t-esc="title"/>
<strong class="px-1">
<t t-call="web.DomainSelector.connector.title" />
</strong>
</t>
<t t-else="">
<div class="d-inline-flex o_domain_tree_connector_selector" aria-atomic="true">
<Dropdown togglerClass="'btn btn-link btn-primary py-0 px-1 o_domain_tree_connector_caret'">
<div aria-atomic="true">
<Dropdown togglerClass="'btn btn-link btn-primary py-0 px-1'" showCaret="true">
<t t-set-slot="toggler">
<t t-esc="title"/>
<t t-call="web.DomainSelector.connector.title" />
</t>
<DropdownItem onSelected="() => this.updateBranchConnector(node, '&amp;')">all</DropdownItem>
<DropdownItem onSelected="() => this.updateBranchConnector(node, '|')">any</DropdownItem>
@@ -123,65 +130,84 @@
</t>
</t>
<t t-name="web._DomainSelector.connector.children">
<div class="o_domain_node_children_container">
<t t-foreach="node.children" t-as="child" t-key="child.type + '_' + child_index">
<t t-call="web._DomainSelector.{{ child.type }}">
<t t-name="web.DomainSelector.connector.children">
<t t-foreach="node.children" t-as="child" t-key="child.type + '_' + child_index">
<div class="o_domain_selector_node" t-att-class="{ 'ps-4': addPadding }">
<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.condition">
<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.condition:readonly" />
</div>
</t>
<t t-else="">
<t t-call="web._DomainSelector.controls" />
<div class="o_domain_leaf_edition">
<t t-call="web._DomainSelector.condition:editable" />
</div>
</t>
</div>
</t>
<t t-name="web._DomainSelector.condition:readonly">
<ModelFieldSelector path="node.path" resModel="props.resModel" readonly="true" />
<t t-set="description" t-value="getDescription(node)"/>
<span class="o_domain_leaf_operator"> <t t-esc="description.operatorDescription" /> </span>
<t t-if="description.valueDescription">
<t t-set="values" t-value="description.valueDescription.values"/>
<t t-set="join" t-value="description.valueDescription.join"/>
<t t-set="addParenthesis" t-value="description.valueDescription.addParenthesis"/>
<t t-if="addParenthesis">( </t>
<t t-foreach="values" t-as="val" t-key="val_index">
<span class="o_domain_leaf_value text-primary"><t t-esc="val"/></span>
<t t-if="!val_last"> <t t-esc="join" /> </t>
</t>
<t t-if="addParenthesis"> )</t>
</t>
</t>
<t t-name="web._DomainSelector.condition:editable">
<PathEditor path="node.path" resModel="props.resModel" readonly="false" update="(path, fieldInfo) => this.updatePath(node, path, fieldInfo)" isDebugMode="props.isDebugMode" />
<div>
<select class="o_domain_leaf_operator_select o_input text-truncate pe-3" t-on-change="(ev) => this.updateLeafOperator(node, ev.target.value)">
<t t-foreach="getOperatorsInfo(node)" t-as="operatorInfo" t-key="operatorInfo.key">
<option t-att-value="operatorInfo.key" t-att-selected="node.operator === operatorInfo.operator and node.negate === operatorInfo.negate" t-esc="operatorInfo.label" />
</t>
</select>
</div>
<t t-if="getOperatorInfo(node).valueCount !== 0">
<div class="o_ds_value_cell">
<Editor info="getEditorInfo(node)" value="node.value" update="(value) => this.updateLeafValue(node, value)" fieldDef="getFieldDef(node.path)"/>
</div>
</t>
</t>
<t t-name="web.DomainSelector.connector">
<div class="o_domain_selector_row d-flex align-items-center">
<div class="o_domain_selector_connector d-flex flex-grow-1">
<t t-call="web.DomainSelector.connector.dropdown" />
<span>of:</span>
</div>
<t t-if="!props.readonly">
<t t-call="web.DomainSelector.controls" />
</t>
</div>
<t t-call="web.DomainSelector.connector.children">
<t t-set="addPadding" t-value="true" />
</t>
</t>
<t t-name="web.DomainSelector.condition">
<div class="o_domain_selector_row d-flex align-items-center">
<t t-if="props.readonly">
<t t-call="web.DomainSelector.condition:readonly" />
</t>
<t t-else="">
<t t-call="web.DomainSelector.condition:editable" />
<t t-call="web.DomainSelector.controls" />
</t>
</div>
</t>
<t t-name="web.DomainSelector.condition:readonly">
<t t-set="description" t-value="getDescription(node)" />
<div class="o_domain_selector_condition d-flex gap-1 px-2 border bg-100">
<ModelFieldSelector path="node.path" resModel="props.resModel" readonly="true" />
<span class="fst-italic" t-esc="description.operatorDescription" />
<t t-if="description.valueDescription">
<t t-set="values" t-value="description.valueDescription.values" />
<t t-set="join" t-value="description.valueDescription.join" />
<t t-set="addParenthesis" t-value="description.valueDescription.addParenthesis" />
<t t-if="addParenthesis">( </t>
<t t-foreach="values" t-as="val" t-key="val_index">
<span class="text-primary fw-bolder"><t t-esc="val" /></span>
<t t-if="!val_last"> <t t-esc="join" /> </t>
</t>
<t t-if="addParenthesis"> )</t>
</t>
</div>
</t>
<t t-name="web.DomainSelector.condition:editable">
<div class="o_domain_selector_condition row flex-nowrap align-items-end flex-grow-1">
<t t-call="web.DomainSelector.Editor">
<t t-set="_classes" t-value="'col col-md-4 pe-0'" />
<t t-set="info" t-value="getPathEditorInfo(node)" />
<t t-set="value" t-value="node.path" />
<t t-set="update" t-value="(path) => this.updatePath(node, path)" />
</t>
<t t-call="web.DomainSelector.Editor">
<t t-set="_classes" t-value="'col col-md-2 px-3'" />
<t t-set="info" t-value="getOperatorEditorInfo(node)" />
<t t-set="value" t-value="[node.operator, node.negate]" />
<t t-set="update" t-value="(operator, negate) => this.updateLeafOperator(node, operator, negate)" />
</t>
<t t-call="web.DomainSelector.Editor">
<t t-set="_classes" t-value="'col ps-0 overflow-hidden'" />
<t t-set="info" t-value="getValueEditorInfo(node)" />
<t t-set="value" t-value="node.value" />
<t t-set="update" t-value="(value) => this.updateLeafValue(node, value)" />
</t>
</div>
</t>
</templates>
@@ -0,0 +1,57 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { TagsList } from "@web/core/tags_list/tags_list";
export class Input extends Component {
static props = ["value", "update", "startEmpty?"];
static template = "web.DomainSelector.Input";
}
export class Select extends Component {
static props = ["value", "update", "options", "addBlankOption?"];
static template = "web.DomainSelector.Select";
deserialize(value) {
return JSON.parse(value);
}
serialize(value) {
return JSON.stringify(value);
}
}
export class Range extends Component {
static props = ["value", "update", "editorInfo"];
static template = "web.DomainSelector.Range";
update(index, newValue) {
const result = [...this.props.value];
result[index] = newValue;
return this.props.update(result);
}
}
export class List extends Component {
static components = { TagsList };
static props = ["value", "update", "editorInfo"];
static template = "web.DomainSelector.List";
get tags() {
const { isSupported, stringify } = this.props.editorInfo;
return this.props.value.map((val, index) => ({
text: stringify(val),
colorIndex: isSupported(val) ? 0 : 2,
onDelete: () => {
this.props.update([
...this.props.value.slice(0, index),
...this.props.value.slice(index + 1),
]);
},
}));
}
update(newValue) {
return this.props.update([...this.props.value, newValue]);
}
}
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelector.Editor">
<t t-if="!info.isSupported(value)">
<div t-attf-class="o_domain_selector_editor #{_classes}">
<div class="o_input d-flex align-items-center">
<span class="flex-grow-1 text-truncate" t-esc="info.stringify(value)" />
<i role="alert" class="fa fa-exclamation-triangle text-warning mx-2" t-att-title="(typeof info.message === 'function') ? info.message(value) : info.message" />
<i class="fa fa-times" title="Clear" t-on-click="() => update(info.defaultValue())" />
</div>
</div>
</t>
<t t-elif="info.component">
<div t-attf-class="o_domain_selector_editor #{_classes}">
<t t-component="info.component" t-props="info.extractProps({ update, value })" />
</div>
</t>
</t>
<t t-name="web.DomainSelector.Input">
<input type="text" class="o_input" t-att-value="props.startEmpty ? '' : props.value" t-on-change="(ev) => props.update(ev.target.value)" />
</t>
<t t-name="web.DomainSelector.Select">
<select class="o_input pe-3 text-truncate" t-on-change="(ev) => props.update(deserialize(ev.target.value))" >
<option t-if="props.addBlankOption" hidden="true" />
<t t-foreach="props.options" t-as="option" t-key="serialize(option[0])">
<option t-att-value="serialize(option[0])" t-att-selected="!props.addBlankOption and option[0] === props.value" t-esc="option[1]" />
</t>
</select>
</t>
<t t-name="web.DomainSelector.Range">
<div class="d-flex align-items-center">
<t t-call="web.DomainSelector.Editor">
<t t-set="_classes" t-value="'overflow-hidden flex-grow-1'"/>
<t t-set="info" t-value="props.editorInfo" />
<t t-set="value" t-value="props.value[0]" />
<t t-set="update" t-value="(val) => this.update(0, val)" />
</t>
<i class="fa fa-long-arrow-right mx-2" aria-label="Arrow icon" title="Arrow" />
<t t-call="web.DomainSelector.Editor">
<t t-set="_classes" t-value="'overflow-hidden flex-grow-1'"/>
<t t-set="info" t-value="props.editorInfo" />
<t t-set="value" t-value="props.value[1]" />
<t t-set="update" t-value="(val) => this.update(1, val)" />
</t>
</div>
</t>
<t t-name="web.DomainSelector.List">
<div class="o_input d-flex flex-wrap gap-1">
<TagsList tags="tags"/>
<div class="flex-grow-1">
<t t-call="web.DomainSelector.Editor">
<t t-set="info" t-value="props.editorInfo" />
<t t-set="value" t-value="props.editorInfo.defaultValue()" />
<t t-set="update" t-value="(val) => this.update(val)" />
</t>
</div>
</div>
</t>
</templates>
@@ -1,517 +0,0 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { selectOperators } from "@web/core/domain_selector/domain_selector_operators";
import {
deserializeDate,
deserializeDateTime,
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
import { _t } 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";
import { DateTimeInput } from "../datetime/datetime_input";
import { TagsList } from "@web/core/tags_list/tags_list";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import { Expression, formatValue } from "@web/core/domain_tree";
import {
DomainSelectorAutocomplete,
DomainSelectorSingleAutocomplete,
} from "./domain_selector_autocomplete";
const { DateTime } = luxon;
const identity = (x) => x;
const isString = (value) => typeof value === "string";
// ============================================================================
export class Editor extends Component {
static props = ["info", "value", "update", "fieldDef?"];
static template = "web.DomainSelector.Editor";
get stringifiedValue() {
return formatValue(this.props.value);
}
}
export class PathEditor extends Editor {
static props = ["isDebugMode", "readonly", "resModel", "path", "update"];
static template = "web.DomainSelector.PathEditor";
get component() {
return ModelFieldSelector;
}
get isSupportedPath() {
const { path } = this.props;
return [0, 1].includes(path) || typeof path === "string";
}
get stringifiedPath() {
return formatValue(this.props.path);
}
onClear() {
this.props.update();
}
}
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 components = { TagsList };
static props = ["value", "update"];
static template = "web.DomainSelector.TagInput";
get value() {
return Array.isArray(this.props.value) ? this.props.value : [this.props.value];
}
get tags() {
return this.value.map((val, index) => ({
text: String(val),
colorIndex: typeof val === "string" ? 0 : 2,
onDelete: () => {
this.props.update([...this.value.slice(0, index), ...this.value.slice(index + 1)]);
},
}));
}
onChange(ev) {
const newVal = ev.currentTarget.value;
ev.currentTarget.value = "";
this.props.update([...this.value, newVal]);
}
}
class Range extends Component {
static props = ["value", "update", "editorInfo", "fieldDef"];
static template = "web.DomainSelector.Range";
static components = { Editor };
update(index, newValue) {
const result = [...this.props.value];
result[index] = newValue;
return this.props.update(result);
}
getValue(index) {
return this.props.value[index];
}
getUpdater(index) {
return (newValue) => this.update(index, newValue);
}
}
// ============================================================================
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, isSupported, defaultValue } = {}) {
return {
component,
extractProps: props || (({ value, update }) => ({ value, update })),
isSupported: isSupported || ((value) => !(value instanceof Expression)),
defaultValue:
defaultValue ||
((fieldDef, operator) => {
switch (operator) {
case "in":
case "not in":
return [];
case "set":
case "not_set":
case "child_of":
case "parent_of":
return false;
case "is":
case "is_not":
return true;
case "=like":
case "=ilike":
case "like":
case "not like":
case "ilike":
case "not ilike":
return "";
default: {
let val;
switch (fieldDef?.type) {
case "integer":
case "float":
case "monetary":
val = 1;
break;
case "boolean":
val = false;
break;
default:
val = "";
}
if (operator === "between") {
return [val, val];
}
return val;
}
}
}),
};
}
/**
* @param {"date" | "datetime"} type
* @param {Parameters<serializeDate>} args
*/
const genericSerializeDate = (type, ...args) =>
type === "date" ? serializeDate(...args) : serializeDateTime(...args);
/**
* @param {"date" | "datetime"} type
* @param {Parameters<deserializeDate>} args
*/
const genericDeserializeDate = (type, ...args) =>
type === "date" ? deserializeDate(...args) : deserializeDateTime(...args);
const DEFAULT = {
operators: [
"=",
"!=",
">",
">=",
"<",
"<=",
"ilike",
"not ilike",
"like",
"not like",
"=like",
"=ilike",
"child_of",
"parent_of",
"in",
"not in",
"set",
"not_set",
],
editors: { default: makeEditor(Input) },
defaultValue: () => "",
};
const SET_NOT_SET_EDITOR = {
component: null,
extractProps: identity,
isSupported: (value) => value === false,
};
// ----------------------------------------------------------------------------
const BOOLEAN = {
operators: ["is", "is_not"],
editors: {
default: makeEditor(Select, {
props: ({ value, update }) => ({
value,
update,
options: [
[true, _t("set")],
[false, _t("not set")],
],
}),
}),
},
defaultValue: () => true,
};
// ----------------------------------------------------------------------------
const DATETIME_EDITOR_BETWEEN = makeEditor(Range, {
props: ({ value, update, fieldDef }) => ({
value,
update,
editorInfo: getEditorInfo(fieldDef),
fieldDef,
}),
});
const DATETIME = {
operators: ["=", "!=", ">", ">=", "<", "<=", "between", "set", "not_set"],
editors: {
default: makeEditor(DateTimeInput, {
props: ({ value, update, fieldDef }) => ({
value: genericDeserializeDate(fieldDef.type, value),
type: fieldDef.type,
onApply: (value) =>
update(value ? genericSerializeDate(fieldDef.type, value) : DateTime.local()),
}),
}),
between: DATETIME_EDITOR_BETWEEN,
},
defaultValue: ({ type }) => genericSerializeDate(type, DateTime.local()),
};
// ----------------------------------------------------------------------------
const TEXT = {
operators: ["=", "!=", "ilike", "not ilike", "in", "not in", "set", "not_set"],
editors: {
default: makeEditor(Input),
in: makeEditor(TagInput),
"not in": makeEditor(TagInput),
},
defaultValue: () => "",
};
// ----------------------------------------------------------------------------
const NUMBER = {
operators: ["=", "!=", ">", ">=", "<", "<=", "between", "ilike", "not ilike", "set", "not_set"],
editors: {
default: makeEditor(Input, {
props: ({ value, update, fieldDef }) => ({
value: String(value),
update: (value) => update(parseValue(fieldDef.type, value)),
}),
}),
between: makeEditor(Range, {
props: ({ value, update, fieldDef }) => ({
value,
update,
editorInfo: getEditorInfo(fieldDef),
fieldDef,
}),
}),
},
defaultValue: () => 1,
};
// ----------------------------------------------------------------------------
const isId = (value) => Number.isInteger(value) && value >= 1;
const RELATIONAL_EDITOR_EQUALITY = makeEditor(DomainSelectorSingleAutocomplete, {
props: ({ value, update, fieldDef }) => {
return {
resModel: fieldDef.relation,
fieldString: fieldDef.string,
update,
resId: value,
};
},
isSupported: () => true,
});
RELATIONAL_EDITOR_EQUALITY.shouldResetValue = (value) => value !== false || !isId(value);
const RELATIONAL_EDITOR_IN = makeEditor(DomainSelectorAutocomplete, {
props: ({ value, update, fieldDef }) => {
return {
resModel: fieldDef.relation,
fieldString: fieldDef.string,
update: (resIds) => update([...new Set(resIds)]),
resIds: Array.isArray(value) ? [...new Set(value)] : value,
};
},
isSupported: (value) => Array.isArray(value),
});
const MANY2ONE = {
operators: ["in", "not in", "=", "!=", "ilike", "not ilike", "set", "not_set"],
editors: {
default: makeEditor(Input, { isSupported: isString }), // ilike, not ilike + others (found in original domain)
"=": RELATIONAL_EDITOR_EQUALITY,
"!=": RELATIONAL_EDITOR_EQUALITY,
in: RELATIONAL_EDITOR_IN,
"not in": RELATIONAL_EDITOR_IN,
set: SET_NOT_SET_EDITOR,
not_set: SET_NOT_SET_EDITOR,
},
defaultValue: (_, operator) => {
switch (operator) {
case "in":
case "not in":
return [];
case "=":
case "!=":
case "set":
case "not_set":
return false;
default:
return "";
}
},
};
const X2MANY = {
operators: ["in", "not in", "=", "!=", "ilike", "not ilike", "set", "not_set"],
editors: {
default: makeEditor(Input, { isSupported: isString }), // ilike, not ilike + others (found in original domain)
in: RELATIONAL_EDITOR_IN,
"not in": RELATIONAL_EDITOR_IN,
"=": RELATIONAL_EDITOR_IN,
"!=": RELATIONAL_EDITOR_IN,
set: SET_NOT_SET_EDITOR,
not_set: SET_NOT_SET_EDITOR,
},
defaultValue: (_, operator) => {
switch (operator) {
case "in":
case "not in":
case "=":
case "!=":
return [];
case "set":
case "not_set":
return false;
default:
return "";
}
},
};
// ----------------------------------------------------------------------------
const SELECTION_EDITOR_IN = makeEditor(Input, {
props: ({ value, update }) => ({
value: formatAST(toPyValue(value)),
update: (value) => update(evaluateExpr(value)),
}),
});
const SELECTION = {
operators: ["=", "!=", "in", "not in", "set", "not_set"],
editors: {
default: makeEditor(Select, {
props: ({ value, update, fieldDef }) => ({
value,
update,
options: fieldDef.selection || [],
}),
}),
in: SELECTION_EDITOR_IN,
"not in": SELECTION_EDITOR_IN,
},
defaultValue: (fieldDef) => fieldDef.selection[0][0] ?? false,
};
// ----------------------------------------------------------------------------
const PROPERTIES = {
operators: ["set", "not_set"],
defaultValue: () => false,
};
const PROPERTIES_SELECTION = {
operators: ["=", "!=", "set", "not_set"],
editors: {
default: makeEditor(Select, {
props: ({ value, update, fieldDef }) => ({
value,
update,
options: fieldDef.selection || [],
}),
}),
},
defaultValue: ({ selection }) => selection?.[0]?.[0] ?? false,
};
const PROPERTIES_RELATIONAL = {
operators: ["=", "!=", "set", "not_set"],
editors: { default: makeEditor(Input) },
defaultValue: (fieldDef) => (fieldDef.type === "many2one" ? 1 : []),
};
// ----------------------------------------------------------------------------
const JSON_FIELD = {
operators: ["=", "!=", "ilike", "not ilike", "set", "not_set"],
editors: {
default: makeEditor(Input),
},
defaultValue: () => "",
};
// ============================================================================
export const FIELD_DESCRIPTIONS = {
boolean: BOOLEAN,
char: TEXT,
date: DATETIME,
datetime: DATETIME,
float: NUMBER,
html: TEXT,
integer: NUMBER,
json: JSON_FIELD,
many2many: X2MANY,
many2one: MANY2ONE,
monetary: NUMBER,
one2many: X2MANY,
properties: PROPERTIES,
properties_definition: PROPERTIES,
selection: SELECTION,
text: TEXT,
};
const PROPERTIES_DESCRIPTIONS = {
selection: PROPERTIES_SELECTION,
many2many: PROPERTIES_RELATIONAL,
many2one: PROPERTIES_RELATIONAL,
one2many: PROPERTIES_RELATIONAL,
tags: PROPERTIES_RELATIONAL,
};
export function getFieldInfo(fieldDef) {
const { is_property, type } = fieldDef || {};
if (is_property && PROPERTIES_DESCRIPTIONS[type]) {
return PROPERTIES_DESCRIPTIONS[type];
} else if (FIELD_DESCRIPTIONS[type]) {
return FIELD_DESCRIPTIONS[type];
} else {
return DEFAULT;
}
}
export function getEditorInfo(fieldDef, operator) {
const descr = getFieldInfo(fieldDef);
const editorInfo = descr.editors[operator] || descr.editors.default;
editorInfo.defaultValue = () => getDefaultValue(fieldDef, operator);
return editorInfo;
}
export function getOperatorsInfo(fieldDef) {
const descr = getFieldInfo(fieldDef);
return selectOperators(descr.operators);
}
export function getDefaultValue(fieldDef, operator) {
const descr = getFieldInfo(fieldDef);
return descr.defaultValue(fieldDef, operator);
}
export function getDefaultOperator(fieldDef) {
const descr = getFieldInfo(fieldDef);
return descr.operators[0];
}
@@ -1,61 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelector.Editor">
<t t-if="props.info.isSupported(props.value)">
<t t-component="props.info.component" t-props="props.info.extractProps({ value: props.value, update: props.update, fieldDef: props.fieldDef })" />
</t>
<t t-else="">
<div class="o_ds_expr_value o_input d-flex align-items-center p-0">
<span class="ms-2" t-esc="stringifiedValue" />
<i role="alert" class="ms-auto fa fa-exclamation-triangle text-warning" title="Value not supported" />
<button class="btn btn-link p-1" title="Clear" t-on-click="() => this.props.update(props.info.defaultValue())">
<i class="fa fa-times" />
</button>
</div>
</t>
</t>
<t t-name="web.DomainSelector.PathEditor">
<t t-if="isSupportedPath">
<t t-component="component" isDebugMode="props.isDebugMode" readonly="props.readonly" resModel="props.resModel" path="props.path" update="props.update"/>
</t>
<t t-else="">
<div class="d-flex align-items-center p-0">
<span class="ms-2" t-esc="stringifiedPath" />
<i role="alert" class="ms-auto fa fa-exclamation-triangle text-warning" title="Path not supported" />
<button class="btn btn-link p-1" title="Clear" t-on-click="onClear">
<i class="fa fa-times" />
</button>
</div>
</t>
</t>
<t t-name="web.DomainSelector.Input">
<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">
<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">
<div class="d-flex flex-wrap">
<TagsList tags="tags"/>
<input class="o_input o_domain_leaf_value_input flex-grow-1" t-on-change="onChange"/>
</div>
</t>
<t t-name="web.DomainSelector.Range">
<div class="d-flex h-100 align-items-center">
<Editor info="props.editorInfo" value="getValue(0)" update="getUpdater(0)" fieldDef="props.fieldDef" />
<i class="fa fa-long-arrow-right mx-2" aria-label="Arrow icon" title="Arrow" />
<Editor info="props.editorInfo" value="getValue(1)" update="getUpdater(1)" fieldDef="props.fieldDef" />
</div>
</t>
</templates>
@@ -0,0 +1,181 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { formatValue, TERM_OPERATORS_NEGATION, toValue } from "@web/core/domain_tree";
import { parseExpr } from "@web/core/py_js/py";
import { Select } from "./domain_selector_components";
const OPERATOR_DESCRIPTIONS = {
// valid operators (see TERM_OPERATORS in expression.py)
"=": "=",
"!=": "!=",
"<=": "<=",
"<": "<",
">": ">",
">=": ">=",
"=?": "=?",
"=like": _t("=like"),
"=ilike": _t("=ilike"),
like: _t("like"),
"not like": _t("not like"),
ilike: _t("contains"),
"not ilike": _t("does not contain"),
in: _t("is in"),
"not in": _t("is not in"),
child_of: _t("child of"),
parent_of: _t("parent of"),
// virtual operators (replace = and != in some cases)
is: _t("is"),
is_not: _t("is not"),
set: _t("is set"),
not_set: _t("is not set"),
// virtual operator (equivalent to a couple (>=,<=))
between: _t("is between"),
};
function getDisplayedOperators(fieldDef) {
const { type, is_property } = fieldDef;
if (is_property) {
switch (type) {
case "many2many":
case "tags":
return ["in", "not in", "set", "not_set"];
case "many2one":
case "selection":
return ["=", "!=", "set", "not_set"];
}
}
switch (type) {
case "boolean":
return ["is", "is_not"];
case "selection":
return ["=", "!=", "in", "not in", "set", "not_set"];
case "char":
case "text":
case "html":
return ["=", "!=", "ilike", "not ilike", "in", "not in", "set", "not_set"];
case "date":
case "datetime":
return ["=", "!=", ">", ">=", "<", "<=", "between", "set", "not_set"];
case "integer":
case "float":
case "monetary":
return [
"=",
"!=",
">",
">=",
"<",
"<=",
"between",
"ilike",
"not ilike",
"set",
"not_set",
];
case "many2one":
case "many2many":
case "one2many":
return ["in", "not in", "=", "!=", "ilike", "not ilike", "set", "not_set"];
case "json":
return ["=", "!=", "ilike", "not ilike", "set", "not_set"];
case "properties":
return ["set", "not_set"];
case undefined:
return ["="];
default:
return [
"=",
"!=",
">",
">=",
"<",
"<=",
"ilike",
"not ilike",
"like",
"not like",
"=like",
"=ilike",
"child_of",
"parent_of",
"in",
"not in",
"set",
"not_set",
];
}
}
function toKey(operator, negate = false) {
if (!negate && typeof operator === "string" && operator in OPERATOR_DESCRIPTIONS) {
// this case is the main one. We keep it simple
return operator;
}
return JSON.stringify([formatValue(operator), negate]);
}
function toOperator(key) {
if (!key.includes("[")) {
return [key, false];
}
const [expr, negate] = JSON.parse(key);
return [toValue(parseExpr(expr)), negate];
}
export function getOperatorLabel(operator, negate = false) {
let label;
if (typeof operator === "string" && operator in OPERATOR_DESCRIPTIONS) {
if (negate && operator in TERM_OPERATORS_NEGATION) {
return OPERATOR_DESCRIPTIONS[TERM_OPERATORS_NEGATION[operator]];
}
label = OPERATOR_DESCRIPTIONS[operator];
} else {
label = formatValue(operator);
}
if (negate) {
return _t(`not %s`, label);
}
return label;
}
function getOperatorInfo(operator, negate = false) {
const key = toKey(operator, negate);
const label = getOperatorLabel(operator, negate);
return [key, label];
}
export function getOperatorEditorInfo(fieldDef) {
const operators = getDisplayedOperators(fieldDef || {});
const defaultOperator = operators[0];
const operatorsInfo = operators.map((operator) => getOperatorInfo(operator));
return {
component: Select,
extractProps: ({ update, value: [operator, negate] }) => {
const [operatorKey, operatorLabel] = getOperatorInfo(operator, negate);
const options = [...operatorsInfo];
if (!options.some(([key]) => key === operatorKey)) {
options.push([operatorKey, operatorLabel]);
}
return {
value: operatorKey,
update: (operatorKey) => update(...toOperator(operatorKey)),
options,
};
},
defaultValue: () => defaultOperator,
isSupported: ([operator]) =>
typeof operator === "string" && operator in OPERATOR_DESCRIPTIONS, // should depend on fieldDef too... (e.g. parent_id does not always make sense)
message: _t("Operator not supported"),
stringify: ([operator, negate]) => getOperatorLabel(operator, negate),
};
}
export function getDefaultOperator(fieldDef) {
const { defaultValue } = getOperatorEditorInfo(fieldDef);
return defaultValue();
}
@@ -1,99 +0,0 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { formatValue, toValue } from "@web/core/domain_tree";
import { sprintf } from "@web/core/utils/strings";
import { parseExpr } from "@web/core/py_js/py";
/**
* @typedef {Object} OperatorInfo
* @property {import("@web/core/domain_tree").Value} operator
* @property {string} label
* @property {number|"variable"} valueCount
*/
export const OPERATOR_DESCRIPTIONS = {
// valid operators (see TERM_OPERATORS in expression.py)
"=": { label: "=", valueCount: 1 },
"!=": { label: "!=", valueCount: 1 },
"<=": { label: "<=", valueCount: 1 },
"<": { label: "<", valueCount: 1 },
">": { label: ">", valueCount: 1 },
">=": { label: ">=", valueCount: 1 },
"=?": { label: "=?", valueCount: 1 },
"=like": { label: _t("=like"), valueCount: 1 },
"=ilike": { label: _t("=ilike"), valueCount: 1 },
like: { label: _t("like"), valueCount: 1 },
"not like": { label: _t("not like"), valueCount: 1 },
ilike: { label: _t("contains"), valueCount: 1 },
"not ilike": { label: _t("does not contain"), valueCount: 1 },
in: { label: _t("is in"), valueCount: "variable" },
"not in": { label: _t("is not in"), valueCount: "variable" },
child_of: { label: _t("child of"), valueCount: 1 },
parent_of: { label: _t("parent of"), valueCount: 1 },
// virtual operators (replace = and != in some cases)
is: { label: _t("is"), valueCount: 1 },
is_not: { label: _t("is not"), valueCount: 1 },
set: { label: _t("is set"), valueCount: 0 },
not_set: { label: _t("is not set"), valueCount: 0 },
// virtual operator (equivalent to a couple (>=,<=))
between: { label: _t("is between"), valueCount: 2 },
};
function toKey(operator, negate = false) {
if (!negate && typeof operator === "string" && operator in OPERATOR_DESCRIPTIONS) {
// this case is the main one. We keep it simple
return operator;
}
return JSON.stringify([formatValue(operator), negate]);
}
export function toOperator(key) {
if (!key.includes("[")) {
return [key, false];
}
const [expr, negate] = JSON.parse(key);
return [toValue(parseExpr(expr)), negate];
}
/**
* @param {import("@web/core/domain_tree").Value} operator
* @param {boolean} [negate=false]
* @returns {OperatorInfo}
*/
export function getOperatorInfo(operator, negate = false) {
let operatorInfo;
const key = toKey(operator, negate);
if (typeof operator === "string" && operator in OPERATOR_DESCRIPTIONS) {
const { label, valueCount } = OPERATOR_DESCRIPTIONS[operator];
operatorInfo = {
key,
label: label.toString(),
operator,
negate,
valueCount,
};
} else {
operatorInfo = {
key,
label: formatValue(operator),
operator,
negate,
valueCount: 0,
};
}
if (negate) {
operatorInfo.label = sprintf(`not %s`, operatorInfo.label);
}
return operatorInfo;
}
/**
* @param {import("@web/core/domain_tree").Value[]} operators
* @returns {(OperatorInfo)[]}
*/
export function selectOperators(operators) {
return operators.map((operator) => getOperatorInfo(operator));
}
@@ -0,0 +1,25 @@
/** @odoo-module **/
import { formatValue } from "@web/core/domain_tree";
import { _t } from "@web/core/l10n/translation";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
export function getPathEditorInfo(params) {
return {
component: ModelFieldSelector,
extractProps: ({ update, value: path }) => {
const { resModel, isDebugMode } = params;
return {
path,
update,
resModel,
isDebugMode,
readonly: false,
};
},
isSupported: (path) => [0, 1].includes(path) || typeof path === "string",
defaultValue: () => params.defaultPath,
stringify: (path) => formatValue(path),
message: _t("Invalid field chain"),
};
}
@@ -0,0 +1,323 @@
/** @odoo-module **/
import {
deserializeDate,
deserializeDateTime,
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { DateTimeInput } from "@web/core/datetime/datetime_input";
import {
DomainSelectorAutocomplete,
DomainSelectorSingleAutocomplete,
} from "./domain_selector_autocomplete";
import { unique } from "@web/core/utils/arrays";
import { Input, Select, List, Range } from "./domain_selector_components";
import { formatValue } from "@web/core/domain_tree";
const { DateTime } = luxon;
// ============================================================================
const formatters = registry.category("formatters");
const parsers = registry.category("parsers");
function parseValue(fieldType, value) {
const parser = parsers.get(fieldType, (value) => value);
try {
return parser(value);
} catch {
return value;
}
}
function isParsable(fieldType, value) {
const parser = parsers.get(fieldType, (value) => value);
try {
parser(value);
} catch {
return false;
}
return true;
}
function genericSerializeDate(type, value) {
return type === "date" ? serializeDate(value) : serializeDateTime(value);
}
function genericDeserializeDate(type, value) {
return type === "date" ? deserializeDate(value) : deserializeDateTime(value);
}
function isId(value) {
return Number.isInteger(value) && value >= 1;
}
export function disambiguate(value, displayNames) {
if (!Array.isArray(value)) {
return value === "";
}
let hasSomeString = false;
let hasSomethingElse = false;
for (const val of value) {
if (val === "") {
return true;
}
if (typeof val === "string" || (displayNames && isId(val))) {
hasSomeString = true;
} else {
hasSomethingElse = true;
}
}
return hasSomeString && hasSomethingElse;
}
const STRING_EDITOR = {
component: Input,
extractProps: ({ value, update }) => ({ value, update }),
isSupported: (value) => typeof value === "string",
defaultValue: () => "",
};
function makeSelectEditor(options, params = {}) {
const getOption = (value) => options.find(([v]) => v === value) || null;
return {
component: Select,
extractProps: ({ value, update }) => ({
value,
update,
options,
addBlankOption: params.addBlankOption,
}),
isSupported: (value) => Boolean(getOption(value)),
defaultValue: () => options[0]?.[0] ?? false,
stringify: (value, disambiguate) => {
const option = getOption(value);
return option ? option[1] : disambiguate ? formatValue(value) : String(value);
},
message: _t("Value not in selection"),
};
}
function makeAutoCompleteEditor(fieldDef) {
return {
component: DomainSelectorAutocomplete,
extractProps: ({ value, update }) => {
return {
resModel: fieldDef.is_property ? fieldDef.comodel : fieldDef.relation,
fieldString: fieldDef.string,
update: (value) => update(unique(value)),
resIds: unique(value),
};
},
isSupported: (value) => Array.isArray(value),
defaultValue: () => [],
};
}
// ============================================================================
function getPartialValueEditorInfo(fieldDef, operator, params = {}) {
switch (operator) {
case "set":
case "not_set":
return {
component: null,
extractProps: null,
isSupported: (value) => value === false,
defaultValue: () => false,
};
case "=like":
case "=ilike":
case "like":
case "not like":
case "ilike":
case "not ilike":
return STRING_EDITOR;
case "between": {
const editorInfo = getValueEditorInfo(fieldDef, "=");
return {
component: Range,
extractProps: ({ value, update }) => ({
value,
update,
editorInfo,
}),
isSupported: (value) => Array.isArray(value) && value.length === 2,
defaultValue: () => {
const { defaultValue } = editorInfo;
return [defaultValue(), defaultValue()];
},
};
}
case "in":
case "not in": {
switch (fieldDef.type) {
case "tags":
return STRING_EDITOR;
case "many2one":
case "many2many":
case "one2many":
return makeAutoCompleteEditor(fieldDef);
default: {
const editorInfo = getValueEditorInfo(fieldDef, "=", {
addBlankOption: true,
startEmpty: true,
});
return {
component: List,
extractProps: ({ value, update }) => {
if (!disambiguate(value)) {
const { stringify } = editorInfo;
editorInfo.stringify = (val) => stringify(val, false);
}
return {
value,
update,
editorInfo,
};
},
isSupported: (value) => Array.isArray(value),
defaultValue: () => [],
};
}
}
}
}
const { type } = fieldDef;
switch (type) {
case "integer":
case "float":
case "monetary": {
const formatType = type === "integer" ? "integer" : "float";
return {
component: Input,
extractProps: ({ value, update }) => ({
value: String(value),
update: (value) => update(parseValue(formatType, value)),
startEmpty: params.startEmpty,
}),
isSupported: () => true,
defaultValue: () => 1,
shouldResetValue: (value) => parseValue(formatType, value) === value,
};
}
case "date":
case "datetime":
return {
component: DateTimeInput,
extractProps: ({ value, update }) => ({
value:
params.startEmpty || value === false
? false
: genericDeserializeDate(type, value),
type,
onApply: (value) => {
if (!params.startEmpty || value) {
update(genericSerializeDate(type, value || DateTime.local()));
}
},
}),
isSupported: (value) =>
value === false || (typeof value === "string" && isParsable(type, value)),
defaultValue: () => genericSerializeDate(type, DateTime.local()),
stringify: (value) => {
if (value === false) {
return _t("False");
}
if (typeof value === "string" && isParsable(type, value)) {
const formatter = formatters.get(type, formatValue);
return formatter(genericDeserializeDate(type, value));
}
return formatValue(value);
},
message: _t("Not a valid %s", type),
};
case "char":
case "html":
case "text":
return STRING_EDITOR;
case "boolean": {
if (["is", "is_not"].includes(operator)) {
const options = [
[true, _t("set")],
[false, _t("not set")],
];
return makeSelectEditor(options, params);
}
const options = [
[true, _t("True")],
[false, _t("False")],
];
return makeSelectEditor(options, params);
}
case "many2one": {
if (["=", "!=", "parent_of", "child_of"].includes(operator)) {
return {
component: DomainSelectorSingleAutocomplete,
extractProps: ({ value, update }) => {
return {
resModel: fieldDef.is_property ? fieldDef.comodel : fieldDef.relation,
fieldString: fieldDef.string,
update,
resId: value,
};
},
isSupported: () => true,
defaultValue: () => false,
shouldResetValue: (value) => value !== false && !isId(value),
};
}
break;
}
case "many2many":
case "one2many":
if (["=", "!="].includes(operator)) {
return makeAutoCompleteEditor(fieldDef);
}
break;
case "selection": {
const options = fieldDef.selection || [];
return makeSelectEditor(options, params);
}
}
// Global default for visualization mainly. It is there to visualize what
// has been produced in the debug textarea (in o_domain_selector_debug_container)
// It is hardly useful to produce a string in general.
return {
component: Input,
extractProps: ({ value, update }) => ({
value: String(value),
update,
}),
isSupported: () => true,
defaultValue: () => "",
};
}
export function getValueEditorInfo(fieldDef, operator, options = {}) {
const info = getPartialValueEditorInfo(fieldDef || {}, operator, options);
return {
extractProps: ({ value, update }) => ({ value, update }),
message: _t("Value not supported"),
stringify: (val, disambiguate = true) => {
if (disambiguate) {
return formatValue(val);
}
return String(val);
},
...info,
};
}
export function getDefaultValue(fieldDef, operator, value = null) {
const { isSupported, shouldResetValue, defaultValue } = getValueEditorInfo(fieldDef, operator);
if (value === null || !isSupported(value) || shouldResetValue?.(value)) {
return defaultValue();
}
return value;
}
@@ -1,7 +1,8 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
import { getDefaultValue, getDefaultOperator } from "./domain_selector_fields";
import { disambiguate, getDefaultValue } from "./domain_selector_value_editors";
import { getDefaultOperator, getOperatorLabel } from "./domain_selector_operator_editor";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
import { unique, zip } from "@web/core/utils/arrays";
@@ -14,7 +15,6 @@ import {
formatValue as toSring,
} from "@web/core/domain_tree";
import { useLoadFieldInfo, useLoadPathDescription } from "@web/core/model_field_selector/utils";
import { getOperatorInfo } from "@web/core/domain_selector/domain_selector_operators";
/**
* @param {import("@web/core/domain_tree").Tree} tree
@@ -28,7 +28,10 @@ export function createVirtualOperators(tree, getFieldDef) {
const fieldDef = getFieldDef(path);
if (fieldDef?.type === "boolean") {
return { ...tree, operator: operator === "=" ? "is" : "is_not" };
} else if (fieldDef?.type !== "many2one" && value === false) {
} else if (
!["many2one", "date", "datetime"].includes(fieldDef?.type) &&
value === false
) {
return { ...tree, operator: operator === "=" ? "not_set" : "set" };
}
}
@@ -138,7 +141,7 @@ export function extractIdsFromDomain(domain, getFieldDef) {
const value = toValue(node.value[2]);
const values = Array.isArray(value) ? value : [value];
const ids = values.filter((val) => isId(val));
const resModel = fieldDef.relation;
const resModel = fieldDef.is_property ? fieldDef.comodel : fieldDef.relation;
if (ids.length) {
if (!idsByModel[resModel]) {
idsByModel[resModel] = [];
@@ -186,7 +189,7 @@ function formatValue(val, disambiguate, fieldDef, displayNames) {
}
}
if (fieldDef?.type === "selection") {
const [, label] = (fieldDef.selection || []).find(([v]) => v === val);
const [, label] = (fieldDef.selection || []).find(([v]) => v === val) || [];
if (label !== undefined) {
val = label;
}
@@ -197,35 +200,19 @@ function formatValue(val, disambiguate, fieldDef, displayNames) {
return val;
}
function disambiguate(value, displayNames) {
if (!Array.isArray(value)) {
return value === "";
}
let hasSomeString = false;
let hasSomethingElse = false;
for (const val of value) {
if (val === "") {
return true;
}
if (typeof val === "string" || (displayNames && isId(val))) {
hasSomeString = true;
} else {
hasSomethingElse = true;
}
}
return hasSomeString && hasSomethingElse;
}
export function leafToString(tree, fieldDef, displayNames) {
const { operator, negate, value } = tree;
const operatorLabel = getOperatorLabel(operator, negate);
export function leafToString(fieldDef, operatorInfo, value, displayNames) {
const description = {
operatorDescription: `${operatorInfo.label}`,
operatorDescription: `${operatorLabel}`,
valueDescription: null,
};
if (["set", "not_set"].includes(operatorInfo.operator)) {
if (["set", "not_set"].includes(operator)) {
return description;
}
if (["is", "is_not"].includes(operatorInfo.operator)) {
if (["is", "is_not"].includes(operator)) {
description.valueDescription = {
values: [value ? _t("set") : _t("not set")],
join: "",
@@ -240,7 +227,7 @@ export function leafToString(fieldDef, operatorInfo, value, displayNames) {
);
let join;
let addParenthesis = Array.isArray(value);
switch (operatorInfo.operator) {
switch (operator) {
case "between":
join = _t("and");
addParenthesis = false;
@@ -368,14 +355,12 @@ export function getDomainTreeDescription(
}
return description;
}
const { negate, operator, path, value } = tree;
const { path } = tree;
const fieldDef = getFieldDef(path);
const operatorInfo = getOperatorInfo(operator, negate);
const { operatorDescription, valueDescription } = leafToString(
tree,
fieldDef,
operatorInfo,
value,
displayNames[fieldDef?.relation]
displayNames[fieldDef?.relation || fieldDef?.comodel]
);
let description = `${getDescription(path)} ${operatorDescription} `;
if (valueDescription) {
+1 -1
View File
@@ -3,7 +3,7 @@
import { Domain } from "@web/core/domain";
import { formatAST, toPyValue } from "@web/core/py_js/py_utils";
const TERM_OPERATORS_NEGATION = {
export const TERM_OPERATORS_NEGATION = {
"<": ">=",
">": "<=",
"<=": ">",
@@ -2,13 +2,13 @@
<templates xml:space="preserve">
<t t-name="web._ModelFieldSelector">
<div class="o_model_field_selector flex d-flex" aria-atomic="true" t-att-class="props.readonly ? 'o_read_mode' : 'o_edit_mode o_input'" t-on-click="(ev) => this.openPopover(ev.currentTarget)">
<div class="o_model_field_selector d-flex" aria-atomic="true" t-att-class="props.readonly ? 'o_read_mode' : 'o_edit_mode o_input'" t-on-click="(ev) => this.openPopover(ev.currentTarget)">
<div class="o_model_field_selector_value flex-grow-1 h-100" tabindex="0" t-att-data-tooltip="state.displayNames.join(' > ')" data-tooltip-position="top">
<t t-foreach="state.displayNames" t-as="displayName" t-key="displayName_index">
<t t-if="!displayName_first">
<i class="oi oi-chevron-right m-1" role="img" aria-label="Followed by" title="Followed by" />
</t>
<span class="o_model_field_selector_chain_part px-1 mb-1">
<span t-attf-class="o_model_field_selector_chain_part mb-1 #{props.readonly ? 'border-0 fw-bolder' : 'px-1'} text-nowrap">
<t t-esc="displayName" />
</span>
</t>
@@ -2,14 +2,14 @@
<templates xml:space="preserve">
<t t-name="web.MultiRecordSelector" >
<div class="o_input d-flex flex-wrap h-100 gap-1 o_multi_record_selector">
<div class="o_input d-flex flex-wrap gap-1 o_multi_record_selector">
<TagsList tags="tags"/>
<RecordAutocomplete
resModel="props.resModel"
value="''"
domain="props.domain"
context="props.context"
className="'o_input o_domain_leaf_value_input flex-grow-1'"
className="'o_input flex-grow-1'"
fieldString="props.fieldString"
multiSelect="true"
getIds.bind="getIds"
@@ -7,7 +7,7 @@
value="displayName"
domain="props.domain"
context="props.context"
className="'o_input o_domain_leaf_value_input h-100 o_record_selector'"
className="'o_input h-100 o_record_selector'"
fieldString="props.fieldString"
multiSelect="false"
getIds="() => []"
@@ -35,7 +35,7 @@
</span>
</div>
<div class="o_facet_values position-relative d-flex align-items-center ps-2 rounded-end-2">
<div class="o_facet_values position-relative d-flex align-items-center ps-2 rounded-end-2 text-wrap">
<t t-foreach="facet.values" t-as="facetValue" t-key="facetValue_index">
<em t-if="!facetValue_first" class="o_facet_values_sep small fw-bold mx-1 opacity-50" t-esc="facet.separator"/>
<small class="o_facet_value" t-esc="facetValue"/>
@@ -25,7 +25,7 @@
<div class="o_searchview_facet_label rounded-start-2 btn btn-primary rounded-end-0 p-0" role="button">
<i class="px-1 small fa fa-filter" role="image"/>
</div>
<div class="o_facet_values d-flex align-items-center px-2 bg-200 rounded-end-2">
<div class="o_facet_values d-flex align-items-center px-2 bg-200 rounded-end-2 text-wrap">
<small class="o_facet_value" t-esc="facet"/>
</div>
</div>
@@ -32,7 +32,7 @@
}
}
.o_domain_debug_container {
.o_domain_selector_debug_container {
width: 100%;
display: inline-block !important;
}
File diff suppressed because it is too large Load Diff
@@ -43,7 +43,7 @@ function getDisplayedFieldNames(target) {
);
}
function getModelFieldSelectorValues(target) {
export function getModelFieldSelectorValues(target) {
return getNodesTextContent(target.querySelectorAll("span.o_model_field_selector_chain_part"));
}
@@ -3,7 +3,6 @@
import {
click,
editInput,
editSelect,
getFixture,
getNodesTextContent,
patchDate,
@@ -31,6 +30,7 @@ import { registry } from "@web/core/registry";
import { Component, onWillUpdateProps, xml } from "@odoo/owl";
import { createWebClient, doAction } from "../webclient/helpers";
import { openModelFieldSelectorPopover } from "@web/../tests/core/model_field_selector_tests";
import * as dsHelpers from "@web/../tests/core/domain_selector_tests";
function getDomain(searchable) {
return searchable.env.searchModel.domain;
@@ -1536,7 +1536,7 @@ QUnit.module("Search", (hooks) => {
"Add Custom Filter"
);
assert.containsOnce(target, ".modal .o_domain_selector");
assert.containsOnce(target, ".modal .o_domain_selector .o_domain_leaf");
assert.containsOnce(target, ".modal .o_domain_selector .o_domain_selector_condition");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".modal footer button")), [
"Add",
"Cancel",
@@ -1554,13 +1554,15 @@ QUnit.module("Search", (hooks) => {
});
await toggleSearchBarMenu(target);
await openAddCustomFilterDialog(target);
assert.containsOnce(target, ".modal .o_domain_selector .o_domain_leaf");
assert.containsOnce(target, ".o_domain_leaf .o_model_field_selector_chain_part");
assert.strictEqual(
target.querySelector(".o_domain_leaf .o_model_field_selector_chain_part")
.innerText,
"ID"
assert.containsOnce(
target,
".modal .o_domain_selector .o_domain_selector_condition"
);
assert.containsOnce(
target,
".o_domain_selector_condition .o_model_field_selector_chain_part"
);
assert.strictEqual(dsHelpers.getCurrentPath(target), "ID");
}
);
@@ -1580,13 +1582,15 @@ QUnit.module("Search", (hooks) => {
});
await toggleSearchBarMenu(target);
await openAddCustomFilterDialog(target);
assert.containsOnce(target, ".modal .o_domain_selector .o_domain_leaf");
assert.containsOnce(target, ".o_domain_leaf .o_model_field_selector_chain_part");
assert.strictEqual(
target.querySelector(".o_domain_leaf .o_model_field_selector_chain_part")
.innerText,
"Country"
assert.containsOnce(
target,
".modal .o_domain_selector .o_domain_selector_condition"
);
assert.containsOnce(
target,
".o_domain_selector_condition .o_model_field_selector_chain_part"
);
assert.strictEqual(dsHelpers.getCurrentPath(target), "Country");
}
);
@@ -1599,21 +1603,21 @@ QUnit.module("Search", (hooks) => {
});
await toggleSearchBarMenu(target);
await openAddCustomFilterDialog(target);
assert.containsOnce(target, ".modal .o_domain_selector .o_domain_leaf");
assert.containsOnce(target, ".o_domain_leaf .o_model_field_selector_chain_part");
assert.strictEqual(
target.querySelector(".o_domain_leaf .o_model_field_selector_chain_part").innerText,
"ID"
assert.containsOnce(target, ".modal .o_domain_selector .o_domain_selector_condition");
assert.containsOnce(
target,
".o_domain_selector_condition .o_model_field_selector_chain_part"
);
assert.containsOnce(target, "button.o_domain_tree_connector_caret");
assert.strictEqual(dsHelpers.getCurrentPath(target), "ID");
assert.containsOnce(target, ".o_domain_selector .o_domain_selector_connector");
await click(target, ".o_domain_add_node_button .fa-plus");
assert.containsOnce(target, "button.o_domain_tree_connector_caret");
await dsHelpers.clickOnButtonAddNewRule(target);
assert.containsOnce(target, ".o_domain_selector .dropdown-toggle");
assert.strictEqual(
target.querySelector("button.o_domain_tree_connector_caret").innerText,
target.querySelector(".o_domain_selector .dropdown-toggle").innerText,
"any"
);
assert.containsN(target, ".modal .o_domain_selector .o_domain_leaf", 2);
assert.containsN(target, ".modal .o_domain_selector .o_domain_selector_condition", 2);
});
QUnit.test("Add a custom filter", async function (assert) {
@@ -1642,22 +1646,13 @@ QUnit.module("Search", (hooks) => {
await toggleSearchBarMenu(target);
await openAddCustomFilterDialog(target);
await click(target, ".o_domain_add_node_button .fa-plus");
await dsHelpers.clickOnButtonAddNewRule(target);
const connectorEl = target.querySelector(".o_domain_tree_connector_selector");
await click(connectorEl.querySelector("button.o_domain_tree_connector_caret"));
await click(
[
...target.querySelectorAll(".o_domain_tree_connector_selector .dropdown-item"),
].find((el) => el.innerText === "all")
);
await click(target.querySelector(".o_domain_selector .dropdown .dropdown-toggle"));
await click(target.querySelector(".o_domain_selector .dropdown .dropdown-item"));
await click(
[...target.querySelectorAll(".o_domain_add_node_button .fa-sitemap")].at(-1)
);
await click(
[...target.querySelectorAll(".o_domain_add_node_button .fa-sitemap")].at(-1)
);
await dsHelpers.clickOnButtonAddBranch(target, -1);
await dsHelpers.clickOnButtonAddBranch(target, -1);
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), [
@@ -1704,7 +1699,11 @@ QUnit.module("Search", (hooks) => {
await toggleSearchBarMenu(target);
await openAddCustomFilterDialog(target);
await editInput(target, ".o_domain_debug_input", `[("foo", "in", [uid, 1, "a"])]`);
await editInput(
target,
dsHelpers.SELECTORS.debugArea,
`[("foo", "in", [uid, 1, "a"])]`
);
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), [`Foo is in ( uid , 1 , "a" )`]);
@@ -1734,7 +1733,7 @@ QUnit.module("Search", (hooks) => {
await toggleSearchBarMenu(target);
await openAddCustomFilterDialog(target);
await editInput(target, ".o_domain_debug_input", `[("id", "between", [0, 10])]`);
await editInput(target, dsHelpers.SELECTORS.debugArea, `[("id", "between", [0, 10])]`);
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), [`ID is between 0 and 10`]);
@@ -1761,11 +1760,11 @@ QUnit.module("Search", (hooks) => {
await openAddCustomFilterDialog(target);
await editInput(
target,
".o_domain_debug_input",
dsHelpers.SELECTORS.debugArea,
`["!", "|", ("foo", "=", 1 ), ("id", "=", 2)]`
);
assert.strictEqual(
target.querySelector(".o_domain_tree_connector_caret").textContent,
target.querySelector(".o_domain_selector_row .dropdown-toggle").textContent,
"none"
);
@@ -1797,14 +1796,14 @@ QUnit.module("Search", (hooks) => {
await toggleSearchBarMenu(target);
await openAddCustomFilterDialog(target);
await editSelect(target, ".o_domain_leaf_operator_select", "not_set");
await dsHelpers.selectOperator(target, "not_set");
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), ["ID is not set"]);
assert.deepEqual(getDomain(controlPanel), [["id", "=", false]]);
await click(target, ".o_searchview_facet_label");
await editSelect(target, ".o_domain_leaf_operator_select", "set");
await dsHelpers.selectOperator(target, "set");
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), ["ID is set"]);
@@ -1819,21 +1818,21 @@ QUnit.module("Search", (hooks) => {
assert.deepEqual(getDomain(controlPanel), [["boolean", "=", true]]);
await click(target, ".o_searchview_facet_label");
await editSelect(target, ".o_domain_leaf_value_input", false);
await dsHelpers.selectValue(target, false);
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), ["Boolean is not set"]);
assert.deepEqual(getDomain(controlPanel), [["boolean", "=", false]]);
await click(target, ".o_searchview_facet_label");
await editSelect(target, ".o_domain_leaf_operator_select", "is_not");
await dsHelpers.selectOperator(target, "is_not");
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), ["Boolean is not not set"]);
assert.deepEqual(getDomain(controlPanel), [["boolean", "!=", false]]);
await click(target, ".o_searchview_facet_label");
await editSelect(target, ".o_domain_leaf_value_input", true);
await dsHelpers.selectValue(target, true);
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), ["Boolean is not set"]);
@@ -1871,7 +1870,7 @@ QUnit.module("Search", (hooks) => {
await toggleSearchBarMenu(target);
await openAddCustomFilterDialog(target);
await editInput(target, ".o_domain_debug_input", "[(uid, uid, uid)]");
await editInput(target, dsHelpers.SELECTORS.debugArea, "[(uid, uid, uid)]");
await click(target.querySelector(".modal footer button"));
assert.containsOnce(target, ".modal .o_domain_selector");
});
@@ -1903,7 +1902,7 @@ QUnit.module("Search", (hooks) => {
await openAddCustomFilterDialog(target);
await editInput(
target,
".o_domain_debug_input",
dsHelpers.SELECTORS.debugArea,
`[("bar", "=", 1 ), ("bar", "in", [2, 5555]), ("bar", "!=", false), ("id", "=", 2)]`
);
await click(target.querySelector(".modal footer button"));
@@ -1925,6 +1924,55 @@ QUnit.module("Search", (hooks) => {
]);
});
QUnit.test("display names in facets (with a property)", async function (assert) {
patchWithCleanup(odoo, { debug: true });
serverData.models.partner = {
fields: {},
records: [{ id: 1, display_name: "John" }],
};
async function mockRPC(route, { method, model }) {
if (route === "/web/domain/validate") {
return true;
}
if (method === "web_search_read" && model === "parentModel") {
return {
records: [
{
id: 1337,
display_name: "First Parent",
properties_definition: [
{
name: "m2o",
type: "many2one",
string: "M2O",
comodel: "partner",
},
],
},
],
};
}
}
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: SearchBar,
searchMenuTypes: ["filter"],
searchViewId: false,
searchViewArch: `<search />`,
mockRPC,
});
await toggleSearchBarMenu(target);
await openAddCustomFilterDialog(target);
await editInput(target, dsHelpers.SELECTORS.debugArea, `[("properties.m2o", "=", 1)]`);
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), ["Properties 🠒 M2O = John"]);
assert.deepEqual(getDomain(controlPanel), [["properties.m2o", "=", 1]]);
});
QUnit.test("group by properties", async function (assert) {
assert.expect(11);
@@ -1,5 +1,6 @@
/** @odoo-module **/
import * as dsHelpers from "@web/../tests/core/domain_selector_tests";
import {
click,
getFixture,
@@ -1459,28 +1460,25 @@ QUnit.module("Search", (hooks) => {
assert.containsOnce(target, ".modal");
assert.strictEqual(target.querySelector(".modal header").innerText, "Modify Condition");
assert.containsOnce(target, ".modal .o_domain_selector");
assert.containsOnce(target, ".o_domain_leaf");
assert.containsOnce(target, dsHelpers.SELECTORS.condition);
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".modal footer button")), [
"Confirm",
"Discard",
]);
assert.strictEqual(
target.querySelector(".o_model_field_selector_value").innerText,
"Birthday"
);
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, ">=");
assert.strictEqual(target.querySelector(".o_ds_expr_value").innerText, "context_today()");
assert.strictEqual(dsHelpers.getCurrentPath(target), "Birthday");
assert.strictEqual(dsHelpers.getCurrentOperator(target), ">=");
assert.strictEqual(dsHelpers.getCurrentValue(target), "context_today()");
assert.notOk(target.querySelector(".modal footer button").disabled);
await click(target, ".o_domain_delete_node_button");
assert.containsNone(target, ".o_domain_leaf");
await dsHelpers.clickOnButtonDeleteNode(target);
assert.containsNone(target, dsHelpers.SELECTORS.condition);
assert.ok(target.querySelector(".modal footer button").disabled);
await click(target, ".modal .o_domain_tree a[role=button]");
assert.containsOnce(target, ".o_domain_leaf");
assert.strictEqual(target.querySelector(".o_model_field_selector_value").innerText, "ID");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "=");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "1");
await click(target, `.modal ${dsHelpers.SELECTORS.addNewRule}`);
assert.containsOnce(target, dsHelpers.SELECTORS.condition);
assert.strictEqual(dsHelpers.getCurrentPath(target), "ID");
assert.strictEqual(dsHelpers.getCurrentOperator(target), "=");
assert.strictEqual(dsHelpers.getCurrentValue(target), "1");
await click(target.querySelector(".modal footer button"));
assert.containsNone(target, ".modal");
@@ -1569,9 +1567,9 @@ QUnit.module("Search", (hooks) => {
await click(target, ".o_facet_with_domain .o_searchview_facet_label");
assert.containsOnce(target, ".modal");
assert.strictEqual(target.querySelector(".o_model_field_selector_value").innerText, "Foo");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "ilike");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "abc");
assert.strictEqual(dsHelpers.getCurrentPath(target), "Foo");
assert.strictEqual(dsHelpers.getCurrentOperator(target), "contains");
assert.strictEqual(dsHelpers.getCurrentValue(target), "abc");
await click(target.querySelector(".modal footer button"));
assert.containsNone(target, ".modal");
@@ -1620,19 +1618,12 @@ QUnit.module("Search", (hooks) => {
await click(target, ".o_facet_with_domain .o_searchview_facet_label");
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".o_domain_leaf");
assert.strictEqual(
target.querySelector(".o_model_field_selector_value").innerText,
"Birthday"
);
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "between");
assert.strictEqual(
target.querySelector(".o_ds_value_cell .o_datetime_input:nth-child(1)").value,
"04/01/2023"
);
assert.strictEqual(
target.querySelector(".o_ds_value_cell .o_datetime_input:nth-child(3)").value,
"04/30/2023"
assert.containsOnce(target, dsHelpers.SELECTORS.condition);
assert.strictEqual(dsHelpers.getCurrentPath(target), "Birthday");
assert.strictEqual(dsHelpers.getCurrentOperator(target), "is between");
assert.deepEqual(
[...target.querySelectorAll(`.o_datetime_input`)].map((el) => el.value),
["04/01/2023", "04/30/2023"]
);
await click(target.querySelector(".modal footer button"));
@@ -1674,37 +1665,15 @@ QUnit.module("Search", (hooks) => {
assert.deepEqual(getFacetTexts(target), ["Foo\nabc\nor\ndef"]);
await click(target, ".o_facet_with_domain .o_searchview_facet_label");
assert.containsN(target, ".o_domain_leaf", 2);
assert.containsN(target, dsHelpers.SELECTORS.condition, 2);
assert.strictEqual(
target.querySelector(".o_domain_leaf:nth-child(1) .o_model_field_selector_value")
.innerText,
"Foo"
);
assert.strictEqual(
target.querySelector(".o_domain_leaf:nth-child(1) .o_domain_leaf_operator_select")
.value,
"ilike"
);
assert.strictEqual(
target.querySelector(".o_domain_leaf:nth-child(1) .o_domain_leaf_value_input").value,
"abc"
);
assert.strictEqual(dsHelpers.getCurrentPath(target), "Foo");
assert.strictEqual(dsHelpers.getCurrentOperator(target), "contains");
assert.strictEqual(dsHelpers.getCurrentValue(target), "abc");
assert.strictEqual(
target.querySelector(".o_domain_leaf:nth-child(2) .o_model_field_selector_value")
.innerText,
"Foo"
);
assert.strictEqual(
target.querySelector(".o_domain_leaf:nth-child(2) .o_domain_leaf_operator_select")
.value,
"ilike"
);
assert.strictEqual(
target.querySelector(".o_domain_leaf:nth-child(2) .o_domain_leaf_value_input").value,
"def"
);
assert.strictEqual(dsHelpers.getCurrentPath(target, 1), "Foo");
assert.strictEqual(dsHelpers.getCurrentOperator(target, 1), "contains");
assert.strictEqual(dsHelpers.getCurrentValue(target, 1), "def");
await click(target.querySelector(".modal footer button"));
@@ -14,7 +14,8 @@ import {
triggerEvent,
} from "@web/../tests/helpers/utils";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { getPickerCell } from "../../core/datetime/datetime_test_helpers";
import { getPickerCell } from "@web/../tests/core/datetime/datetime_test_helpers";
import * as dsHelpers from "@web/../tests/core/domain_selector_tests";
let serverData;
let target;
@@ -116,7 +117,7 @@ QUnit.module("Fields", (hooks) => {
});
assert.strictEqual(
target.querySelector(".o_ds_value_cell").textContent,
dsHelpers.getCurrentValue(target),
"uid",
"The widget should show the dynamic filter."
);
@@ -140,12 +141,9 @@ QUnit.module("Fields", (hooks) => {
</form>`,
});
assert.equal(
target.querySelector(".o_ds_value_cell .o_ds_expr_value").innerText,
"context_today()"
);
assert.equal(dsHelpers.getCurrentValue(target), "context_today()");
await click(target, ".o_ds_value_cell .fa.fa-times");
await dsHelpers.clearNotSupported(target);
// Change the date in the datepicker
await click(target, ".o_datetime_input");
@@ -156,10 +154,7 @@ QUnit.module("Fields", (hooks) => {
await clickDiscard(target);
// Open the datepicker again
assert.equal(
target.querySelector(".o_ds_value_cell .o_ds_expr_value").innerText,
"context_today()"
);
assert.equal(dsHelpers.getCurrentValue(target), "context_today()");
}
);
@@ -182,11 +177,11 @@ QUnit.module("Fields", (hooks) => {
});
// As the domain is empty, there should be a button to add a new rule
assert.containsOnce(target, ".o_domain_tree a[role=button]");
assert.containsOnce(target, dsHelpers.SELECTORS.addNewRule);
// Clicking on the button should add the [["id", "=", "1"]] domain, so
// there should be a field selector in the DOM
await click(target, ".o_domain_tree a[role=button]");
await dsHelpers.addNewRule(target);
assert.containsOnce(target, ".o_model_field_selector", "there should be a field selector");
// Focusing the field selector input should open the field selector
@@ -206,9 +201,7 @@ QUnit.module("Fields", (hooks) => {
// associated value should reveal one matched record
await click(document.body.querySelector(".o_model_field_selector_popover_item_name"));
const input = target.querySelector(".o_domain_leaf_value_input");
input.value = 2;
await triggerEvent(input, null, "change");
await dsHelpers.editValue(target, 2);
assert.strictEqual(
target.querySelector(".o_domain_show_selection_button").textContent.trim().substr(0, 2),
@@ -241,7 +234,7 @@ QUnit.module("Fields", (hooks) => {
</form>`,
});
await click(target, ".o_domain_tree a[role=button]");
await dsHelpers.addNewRule(target);
await click(target, ".o_model_field_selector");
await click(
document.body.querySelector(
@@ -510,7 +503,7 @@ QUnit.module("Fields", (hooks) => {
"2 record(s)"
);
await editInput(target, ".o_domain_debug_input", "[['id', '<', 40]]");
await editInput(target, dsHelpers.SELECTORS.debugArea, "[['id', '<', 40]]");
// the count should not be re-computed when editing with the textarea
assert.strictEqual(
target.querySelector(".o_domain_show_selection_button").textContent.trim(),
@@ -578,7 +571,7 @@ QUnit.module("Fields", (hooks) => {
"2 record(s)"
);
await editInput(target, ".o_domain_debug_input", "[['abc', '=', 1]]");
await editInput(target, dsHelpers.SELECTORS.debugArea, "[['abc', '=', 1]]");
// the count should not be re-computed when editing with the textarea
assert.strictEqual(
target.querySelector(".o_domain_show_selection_button").textContent.trim(),
@@ -586,7 +579,7 @@ QUnit.module("Fields", (hooks) => {
);
assert.verifySteps([]);
await editInput(target, ".o_domain_debug_input", "[['abc']]");
await editInput(target, dsHelpers.SELECTORS.debugArea, "[['abc']]");
assert.verifySteps([]);
await clickSave(target);
@@ -650,7 +643,7 @@ QUnit.module("Fields", (hooks) => {
"2 record(s)"
);
await editInput(target, ".o_domain_debug_input", "[['id', '<', 40]]");
await editInput(target, dsHelpers.SELECTORS.debugArea, "[['id', '<', 40]]");
// the count should not be re-computed when editing with the textarea
assert.strictEqual(
target.querySelector(".o_domain_show_selection_button").textContent.trim(),
@@ -747,11 +740,11 @@ QUnit.module("Fields", (hooks) => {
});
await doAction(webClient, 1);
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain);
assert.strictEqual(target.querySelector(dsHelpers.SELECTORS.debugArea).value, rawDomain);
rawDomain = `[("date", ">=", datetime.datetime.combine(context_today() + relativedelta(days = -1), datetime.time(0, 0, 0)).to_utc().strftime("%Y-%m-%d %H:%M:%S"))]`;
await editInput(target, ".o_domain_debug_input", rawDomain);
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain);
await editInput(target, dsHelpers.SELECTORS.debugArea, rawDomain);
assert.strictEqual(target.querySelector(dsHelpers.SELECTORS.debugArea).value, rawDomain);
await clickSave(target);
});
@@ -802,10 +795,9 @@ QUnit.module("Fields", (hooks) => {
"fields_get",
]);
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain);
assert.containsOnce(target, ".o_ds_expr_value", "there should be an expression");
assert.strictEqual(target.querySelector(dsHelpers.SELECTORS.debugArea).value, rawDomain);
await click(target, ".o_ds_expr_value button");
await dsHelpers.clearNotSupported(target);
rawDomain = `[("date", ">=", "2020-09-05")]`;
assert.containsOnce(target, ".o_datetime_input", "there should be a datepicker");
assert.verifySteps(["search_count"]);
@@ -815,19 +807,19 @@ QUnit.module("Fields", (hooks) => {
assert.containsOnce(target, ".o_datetime_picker");
await triggerEvent(window, null, "scroll");
assert.containsOnce(target, ".o_datetime_picker");
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain);
assert.strictEqual(target.querySelector(dsHelpers.SELECTORS.debugArea).value, rawDomain);
assert.verifySteps([]);
// Manually input a date
rawDomain = `[("date", ">=", "2020-09-09")]`;
await editInput(target, ".o_datetime_input", "09/09/2020");
assert.verifySteps(["search_count"]);
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain);
assert.strictEqual(target.querySelector(dsHelpers.SELECTORS.debugArea).value, rawDomain);
// Save
await clickSave(target);
assert.verifySteps(["web_save", "search_count"]);
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain);
assert.strictEqual(target.querySelector(dsHelpers.SELECTORS.debugArea).value, rawDomain);
});
QUnit.test("domain field without model", async function (assert) {
@@ -925,14 +917,14 @@ QUnit.module("Fields", (hooks) => {
}
},
});
assert.containsNone(target, ".o_domain_leaf");
assert.containsNone(target, dsHelpers.SELECTORS.condition);
assert.containsNone(target, ".modal");
await click(target, ".o_field_domain_dialog_button");
assert.containsOnce(target, ".modal");
await click(target, ".modal .o_domain_tree a[role=button]");
await click(target, `.modal ${dsHelpers.SELECTORS.addNewRule}`);
await click(target, ".modal-footer .btn-primary");
assert.containsOnce(target, ".o_domain_leaf");
assert.strictEqual(target.querySelector(".o_domain_leaf").textContent, "ID = 1");
assert.containsOnce(target, dsHelpers.SELECTORS.condition);
assert.strictEqual(dsHelpers.getConditionText(target), "ID = 1");
});
QUnit.test("invalid value in domain field with 'inDialog' options", async function (assert) {
@@ -949,15 +941,15 @@ QUnit.module("Fields", (hooks) => {
<field name="display_name" widget="domain" options="{'model': 'partner', 'in_dialog': True}"/>
</form>`,
});
assert.containsNone(target, ".o_domain_leaf");
assert.containsNone(target, dsHelpers.SELECTORS.condition);
assert.containsNone(target, ".modal");
assert.containsNone(target, ".o_field_domain .text-warning");
await click(target, ".o_field_domain_dialog_button");
assert.containsOnce(target, ".modal");
await click(target, ".modal .o_domain_tree a[role=button]");
await editInput(target, ".o_domain_debug_input", "[(0, '=', expr)]");
await click(target, `.modal ${dsHelpers.SELECTORS.addNewRule}`);
await editInput(target, dsHelpers.SELECTORS.debugArea, "[(0, '=', expr)]");
await click(target, ".modal-footer .btn-primary");
assert.containsOnce(target, ".modal", "the domain is invalid: the dialog is not closed");
});
@@ -990,7 +982,7 @@ QUnit.module("Fields", (hooks) => {
target.querySelector(".o_domain_show_selection_button").textContent.trim(),
"0 record(s)"
);
await editInput(target, ".o_domain_debug_input", "[['id', '!=', False]]");
await editInput(target, dsHelpers.SELECTORS.debugArea, "[['id', '!=', False]]");
await click(target, "button.o_form_button_save");
assert.verifySteps(["/web/domain/validate"]);
assert.strictEqual(
@@ -1027,11 +1019,11 @@ QUnit.module("Fields", (hooks) => {
await click(target, ".o_field_domain > div > div");
// There should be a button to add a new rule
assert.containsOnce(target, ".o_domain_tree a[role=button]");
assert.containsOnce(target, dsHelpers.SELECTORS.addNewRule);
// Clicking on the button should add the [["id", "=", "1"]] domain, so
// there should be a field selector in the DOM
await click(target, ".o_domain_tree a[role=button]");
await dsHelpers.addNewRule(target);
assert.containsOnce(target, ".o_model_field_selector");
// Focusing the field selector input should open the field selector
@@ -1051,7 +1043,7 @@ QUnit.module("Fields", (hooks) => {
// associated value should reveal one matched record
await click(document.body.querySelector(".o_model_field_selector_popover_item_name"));
await editInput(target, ".o_domain_leaf_value_input", 2);
await dsHelpers.editValue(target, 2);
assert.strictEqual(
target.querySelector(".o_domain_show_selection_button").textContent.trim().substr(0, 2),
@@ -1093,7 +1085,7 @@ QUnit.module("Fields", (hooks) => {
// Unfold the domain and delete the condition
await click(target, ".o_field_domain > div > div");
await click(target, ".o_domain_delete_node_button");
await dsHelpers.clickOnButtonDeleteNode(target);
// Fold domain selector
await click(target, ".o_field_domain a i");
@@ -1105,6 +1097,9 @@ QUnit.module("Fields", (hooks) => {
await click(target, ".o_domain_add_first_node_button");
// Domain is now unfolded with the default condition
assert.containsOnce(target, ".o_model_field_selector");
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, '[("id", "=", 1)]');
assert.strictEqual(
target.querySelector(dsHelpers.SELECTORS.debugArea).value,
'[("id", "=", 1)]'
);
});
});