[IMP] *: advanced search

We allow edition/creation of domains with a domain selector via the
search bar facets or the menu "Add Custom Filter".
The domain selector has also been improved and now support expressions
and a new operator "between" for some field types (those for which <=
and >= are valid operators). We also improve the support of the connector
not.

Task ID: 3063564

closes odoo/odoo#112326

Related: odoo/enterprise#37715
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Michaël Mattiello <mcm@odoo.com>
This commit is contained in:
Mathieu Duckerts-Antoine
2023-05-08 15:18:03 +02:00
co-authored by Michaël Mattiello
parent 381da748c3
commit c1436ba8ca
40 changed files with 2394 additions and 1640 deletions
@@ -3,8 +3,6 @@
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import * as testUtils from "@web/../tests/helpers/utils";
import weTestUtils from "web_editor.test_utils";
import { fieldService } from "@web/core/field_service";
import { registry } from "@web/core/registry";
let fixture;
let serverData;
@@ -109,7 +107,6 @@ QUnit.module('favorite filter widget', (hooks) => {
});
serverData = { models };
setupViewRegistries();
registry.category("services").add("field", fieldService);
});
QUnit.test('create favorite filter', async (assert) => {
@@ -1,23 +1,29 @@
/** @odoo-module **/
import {
BranchDomainNode,
DomainValueExpr,
LeafDomainNode,
} from "@web/core/domain_selector/domain_selector_nodes";
import {
buildDomain,
buildDomainSelectorTree,
extractPathsFromDomain,
useGetDefaultLeafDomain,
} from "@web/core/domain_selector/utils";
import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl";
import { Domain } from "@web/core/domain";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
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 { findOperator } from "@web/core/domain_selector/domain_selector_operators";
import {
Editor,
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 {
loadFieldInfo,
ModelFieldSelector,
} from "@web/core/model_field_selector/model_field_selector";
import { useService } from "@web/core/utils/hooks";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import { useLoadFieldInfo } from "@web/core/model_field_selector/utils";
export class DomainSelector extends Component {
static template = "web._DomainSelector";
@@ -25,31 +31,30 @@ export class DomainSelector extends Component {
Dropdown,
DropdownItem,
ModelFieldSelector,
Editor,
};
static props = {
className: { type: String, optional: true },
domain: String,
resModel: String,
value: String,
debugValue: { type: String, optional: true },
className: { type: String, optional: true },
defaultConnector: { type: [{ value: "&" }, { value: "|" }], optional: true },
defaultLeafValue: { type: Array, optional: true },
isDebugMode: { type: Boolean, optional: true },
readonly: { type: Boolean, optional: true },
update: { type: Function, optional: true },
isDebugMode: { type: Boolean, optional: true },
defaultLeafValue: { type: Array, optional: true },
};
static defaultProps = {
className: "",
defaultConnector: "&",
isDebugMode: false,
readonly: true,
update: () => {},
isDebugMode: false,
defaultLeafValue: ["id", "=", 1],
};
setup() {
this.fieldService = useService("field");
this.treeBuilder = new DomainTreeBuilder();
this.tree = useState({
isSupported: false,
root: null,
});
this.getDefaultLeafDomain = useGetDefaultLeafDomain();
this.loadFieldInfo = useLoadFieldInfo();
this.tree = { root: null };
onWillStart(() => this.onPropsUpdated(this.props));
onWillUpdateProps((np) => this.onPropsUpdated(np));
}
@@ -61,71 +66,88 @@ export class DomainSelector extends Component {
}
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 = this.treeBuilder.build(new Domain([]), {});
}
}
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);
}
let defaultLeafDomain;
if (p.defaultLeafValue) {
try {
defaultLeafDomain = new Domain([p.defaultLeafValue]);
} catch {
// nothing
}
}
return fields;
if (!defaultLeafDomain) {
defaultLeafDomain = new Domain(await this.getDefaultLeafDomain(p.resModel));
}
let domain;
let isSupported = true;
try {
domain = new Domain(p.domain);
} catch {
isSupported = false;
}
if (!isSupported) {
this.tree.isSupported = false;
this.tree.root = buildDomainSelectorTree(new Domain(`[]`), {});
return;
}
const paths = new Set([
...extractPathsFromDomain(domain),
...extractPathsFromDomain(defaultLeafDomain),
]);
const pathsInfo = await this.loadPathsInfo(p.resModel, paths);
this.tree.isSupported = true;
const options = { defaultConnector: p.defaultConnector, distributeNot: !p.isDebugMode };
this.tree.root = buildDomainSelectorTree(domain, pathsInfo, options);
this.defaultLeaf = buildDomainSelectorTree(
defaultLeafDomain,
pathsInfo,
options
).children[0];
}
notifyChanges() {
this.props.update(this.tree.root.toDomain().toString());
this.props.update(buildDomain(this.tree.root));
}
async loadFieldDefs(resModel, fields) {
async loadPathsInfo(resModel, paths) {
const promises = [];
const fieldDefs = {};
for (const field of fields) {
const pathsInfo = {};
for (const path of paths) {
promises.push(
this.loadFieldDef(resModel, field).then((info) => {
fieldDefs[field] = info;
this.loadFieldDef(resModel, path).then((pathInfo) => {
pathsInfo[path] = pathInfo;
})
);
}
await Promise.all(promises);
return fieldDefs;
return pathsInfo;
}
async loadFieldDef(resModel, field) {
if ("01".includes(field.toString())) {
return { type: "integer" };
/**
*
* @param {string} resModel
* @param {any} path
* @returns {Object|null}
*/
async loadFieldDef(resModel, path) {
if ([0, 1].includes(path)) {
return { fieldDef: { type: "integer", string: String(path) }, path };
}
const { fieldDef } = await loadFieldInfo(this.fieldService, resModel, field);
return fieldDef;
const { fieldDef } = await this.loadFieldInfo(resModel, path);
return { fieldDef, path };
}
createNewLeaf() {
return this.defaultLeaf.clone();
}
createNewBranch(operator) {
return new BranchDomainNode(operator, [this.createNewLeaf(), this.createNewLeaf()]);
createNewBranch(connector) {
return new BranchDomainNode(connector, [this.createNewLeaf(), this.createNewLeaf()]);
}
insertRootLeaf(parent) {
@@ -134,13 +156,16 @@ export class DomainSelector extends Component {
}
insertLeaf(parent, node) {
parent.insertAfter(node.id, this.createNewLeaf());
parent.insertAfter(
node.id,
node instanceof LeafDomainNode ? node.clone() : this.createNewLeaf()
);
this.notifyChanges();
}
insertBranch(parent, node) {
const nextOperator = parent.operator === "AND" ? "OR" : "AND";
parent.insertAfter(node.id, this.createNewBranch(nextOperator));
const nextConnector = parent.connector === "AND" ? "OR" : "AND";
parent.insertAfter(node.id, this.createNewBranch(nextConnector));
this.notifyChanges();
}
@@ -153,41 +178,44 @@ export class DomainSelector extends Component {
this.notifyChanges();
}
updateBranchOperator(node, operator) {
node.operator = operator;
updateBranchConnector(node, connector) {
node.connector = connector;
node.negate = false;
this.notifyChanges();
}
updateField(node, field, { fieldDef }) {
if (!fieldDef) {
field = "";
fieldDef = { type: "integer" };
}
node.field = { ...fieldDef, name: field };
node.operator = getOperatorsInfo(fieldDef)[0];
node.value = getDefaultFieldValue(fieldDef);
async updatePath(node, path, { fieldDef }) {
const pathInfo = { path, fieldDef };
node.pathInfo = pathInfo;
node.operatorInfo = getOperatorsInfo(pathInfo.fieldDef)[0];
node.value = getDefaultFieldValue(pathInfo.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": {
updateLeafOperator(node, operatorInfo) {
const previousOperator = node.operatorInfo;
node.operatorInfo = findOperator(operatorInfo);
if (previousOperator.valueCount !== node.operatorInfo.valueCount) {
switch (node.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;
}
case "multiple": {
node.value = previousOperator.valueMode === "none" ? [] : [node.value];
// binary operator with a non array value
case 1: {
node.value = getDefaultFieldValue(node.pathInfo.fieldDef);
break;
}
// binary operator with a fixed sized array value
default: {
if (previousOperator.valueMode === "none") {
node.value = getDefaultFieldValue(node.field);
} else {
node.value = node.value[0];
}
const defaultValue = getDefaultFieldValue(node.pathInfo.fieldDef);
node.value = Array(node.operatorInfo.valueCount).fill(defaultValue);
break;
}
}
@@ -200,18 +228,26 @@ export class DomainSelector extends Component {
this.notifyChanges();
}
isExprValue(value) {
return value instanceof DomainValueExpr;
}
removeExprValue(node) {
this.updateLeafValue(node, getDefaultFieldValue(node.pathInfo.fieldDef));
}
onDebugValueChange(value) {
return this.props.update(value, true);
}
getEditorInfo(node) {
return getEditorInfo(node.field, node.operator.key);
return getEditorInfo(node.pathInfo.fieldDef, node.operatorInfo.key);
}
getOperatorsInfo(node) {
const operators = getOperatorsInfo(node.field);
if (!operators.some((op) => op.key === node.operator.key)) {
operators.push(node.operator);
const operators = getOperatorsInfo(node.pathInfo.fieldDef);
if (!operators.some((op) => op.key === node.operatorInfo.key)) {
operators.push(node.operatorInfo);
}
return operators;
}
@@ -45,7 +45,7 @@
> .o_domain_tree_header {
position: relative;
}
.o_domain_tree_operator_caret::after {
.o_domain_tree_connector_caret::after {
@include o-caret-down;
}
@@ -9,7 +9,7 @@
<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
<i class="fa fa-plus"/> Add condition
</button>
</t>
</t>
@@ -19,7 +19,7 @@
</t>
<t t-else="">
<span>Match records with </span>
<t t-call="web._DomainSelector.branch.operator" />
<t t-call="web._DomainSelector.branch.connector" />
<span> of the following rules:</span>
<t t-call="web._DomainSelector.branch.children" />
</t>
@@ -33,7 +33,7 @@
<t t-if="props.isDebugMode and (!tree.isSupported or !props.readonly)">
<label class="o_domain_debug_container">
<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.debugValue or props.value" t-on-change="(ev) => this.onDebugValueChange(ev.target.value)" />
<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)" />
</label>
</t>
</div>
@@ -80,31 +80,27 @@
<t t-if="!props.readonly">
<t t-call="web._DomainSelector.controls" />
</t>
<t t-call="web._DomainSelector.branch.operator" />
<t t-call="web._DomainSelector.branch.connector" />
<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-name="web._DomainSelector.branch.connector" 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>
<t t-esc="node.title"/>
</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'">
<div class="d-inline-flex o_domain_tree_connector_selector" aria-atomic="true">
<Dropdown togglerClass="'btn btn-link btn-sm btn-primary py-0 px-1 o_domain_tree_connector_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 t-esc="node.title"/>
</t>
<DropdownItem onSelected="() => this.updateBranchOperator(node, 'AND')">all</DropdownItem>
<DropdownItem onSelected="() => this.updateBranchOperator(node, 'OR')">any</DropdownItem>
<DropdownItem onSelected="() => this.updateBranchConnector(node, 'AND')">all</DropdownItem>
<DropdownItem onSelected="() => this.updateBranchConnector(node, 'OR')">any</DropdownItem>
</Dropdown>
</div>
</t>
@@ -138,36 +134,37 @@
</t>
<t t-name="web._DomainSelector.leaf:readonly" owl="1">
<ModelFieldSelector path="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>
<ModelFieldSelector path="node.pathInfo.path" resModel="props.resModel" readonly="true" />
<t t-if="typeof node.value === 'boolean'">is<t t-if="node.value ? ['is_not', 'not_equal'].includes(node.operatorInfo.key) : ['is', 'equal'].includes(node.operatorInfo.key)"> not</t> set</t>
<t t-else="">
<span class="o_domain_leaf_operator"> <t t-esc="node.operator.label" /></span>
<span class="o_domain_leaf_operator"> <t t-esc="node.operatorInfo.label" /></span>
<t t-set="values" t-value="Array.isArray(node.value) ? node.value : [node.value]" />
<t t-set="operator" t-value="node.operatorInfo.key" />
<t t-set="connector" t-value="node.operatorInfo.key === 'between' ? 'and' : 'or'" />
<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-elif="isExprValue(value)"> <t t-esc="node.value.expr"/></t>
<t t-else=""> <t t-esc="value"/></t>
</span>
<t t-if="!value_last"> or </t>
<t t-if="!value_last"> <t t-esc="connector" /> </t>
</t>
</t>
</t>
<t t-name="web._DomainSelector.leaf:editable" owl="1">
<ModelFieldSelector path="node.field.name" resModel="props.resModel" readonly="false" update="(path, fieldInfo) => this.updateField(node, path, fieldInfo)" isDebugMode="props.isDebugMode" />
<ModelFieldSelector path="node.pathInfo.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="operator" t-key="operator.key">
<option t-att-value="operator.key" t-att-selected="node.operator.key === operator.key" t-esc="operator_value.label" />
<option t-att-value="operator.key" t-att-selected="node.operatorInfo.key === operator.key" t-esc="operator_value.label" />
</t>
</select>
</div>
<t t-if="node.operator.valueMode !== 'none'">
<t t-if="node.operatorInfo.valueCount">
<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 })" />
<Editor info="getEditorInfo(node)" value="node.value" update="(value) => this.updateLeafValue(node, value)" fieldDef="node.pathInfo.fieldDef" onExprRemove="() => this.removeExprValue(node)" />
</div>
</t>
</t>
@@ -13,11 +13,21 @@ 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 { DomainValueExpr } from "./domain_selector_nodes";
const { DateTime } = luxon;
// ============================================================================
export class Editor extends Component {
static props = ["info", "value", "update", "fieldDef?", "onExprRemove"];
static template = "web.DomainSelector.Editor";
get isExprValue() {
return this.props.value instanceof DomainValueExpr;
}
}
class Input extends Component {
static props = ["value", "update"];
static template = "web.DomainSelector.Input";
@@ -30,6 +40,7 @@ class Select extends Component {
deserialize(value) {
return JSON.parse(value);
}
serialize(value) {
return JSON.stringify(value);
}
@@ -43,6 +54,10 @@ class TagInput extends Component {
this.inputRef = useRef("input");
}
getTagValue(tag) {
return tag instanceof DomainValueExpr ? tag.expr : tag;
}
removeTag(tagIndex) {
return this.props.update([
...this.props.value.slice(0, tagIndex),
@@ -61,6 +76,34 @@ class TagInput extends Component {
}
}
class Range extends Component {
static props = ["value", "update", "component", "subProps", "getValueOnExprRemove"];
static template = "web.DomainSelector.Range";
static components = { Editor };
get editorInfo() {
return { component: this.props.component, extractProps: this.props.subProps };
}
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);
}
getExprRemover(index) {
return () => this.update(index, this.props.getValueOnExprRemove());
}
}
// ============================================================================
const parsers = registry.category("parsers");
@@ -76,7 +119,7 @@ function parseValue(fieldType, value) {
function makeEditor(component, props) {
return {
component,
props: props || (({ value, update }) => ({ value, update })),
extractProps: props || (({ value, update }) => ({ value, update })),
};
}
@@ -138,6 +181,19 @@ const BOOLEAN = {
// ----------------------------------------------------------------------------
const DATETIME_EDITOR_BETWEEN = makeEditor(Range, ({ value, update, fieldDef }) => ({
value,
update,
getValueOnExprRemove: () => genericSerializeDate(fieldDef.type, DateTime.local()),
component: DateTimeInput,
subProps: ({ value, update }) => ({
value: genericDeserializeDate(fieldDef.type, value),
type: fieldDef.type,
onApply: (value) =>
update(value ? genericSerializeDate(fieldDef.type, value) : DateTime.local()),
}),
}));
const DATETIME = {
operators: [
"equal",
@@ -146,16 +202,18 @@ const DATETIME = {
"greater_equal",
"less_than",
"less_equal",
"between",
"set",
"not_set",
],
editors: {
default: makeEditor(DateTimeInput, ({ field, value, update }) => ({
value: genericDeserializeDate(field.type, value),
type: field.type,
default: makeEditor(DateTimeInput, ({ value, update, fieldDef }) => ({
value: genericDeserializeDate(fieldDef.type, value),
type: fieldDef.type,
onApply: (value) =>
update(value ? genericSerializeDate(field.type, value) : DateTime.local()),
update(value ? genericSerializeDate(fieldDef.type, value) : DateTime.local()),
})),
between: DATETIME_EDITOR_BETWEEN,
},
defaultValue: ({ type }) => genericSerializeDate(type, DateTime.local()),
};
@@ -163,7 +221,7 @@ const DATETIME = {
// ----------------------------------------------------------------------------
const TEXT = {
operators: ["equal", "not_equal", "ilike", "not_ilike", "set", "not_set", "in", "not_in"],
operators: ["equal", "not_equal", "ilike", "not_ilike", "in", "not_in", "set", "not_set"],
editors: {
default: makeEditor(Input),
in: makeEditor(TagInput),
@@ -182,15 +240,26 @@ const NUMBER = {
"greater_equal",
"less_than",
"less_equal",
"between",
"ilike",
"not_ilike",
"set",
"not_set",
],
editors: {
default: makeEditor(Input, ({ value, update, field }) => ({
value: `${value}`,
update: (value) => update(parseValue(field.type, value)),
default: makeEditor(Input, ({ value, update, fieldDef }) => ({
value: String(value),
update: (value) => update(parseValue(fieldDef.type, value)),
})),
between: makeEditor(Range, ({ value, update, fieldDef }) => ({
value,
update,
getValueOnExprRemove: () => 1,
component: Input,
subProps: ({ value, update }) => ({
value: String(value),
update: (newValue) => update(parseValue(fieldDef.type, newValue)),
}),
})),
},
defaultValue: () => 1,
@@ -198,10 +267,19 @@ const NUMBER = {
// ----------------------------------------------------------------------------
const RELATIONAL_EDITOR_EQUALITY = makeEditor(Input, ({ value, update }) => ({
value: `${value}`,
update: (value) => update(parseValue("integer", value)),
}));
const RELATIONAL = {
operators: ["equal", "not_equal", "ilike", "not_ilike", "set", "not_set"],
editors: { default: makeEditor(Input) },
defaultValue: (field) => (field.type === "many2one" ? 1 : []),
editors: {
default: makeEditor(Input),
equal: RELATIONAL_EDITOR_EQUALITY,
not_equal: RELATIONAL_EDITOR_EQUALITY,
},
defaultValue: ({ type }) => (type === "many2one" ? 1 : []),
};
// ----------------------------------------------------------------------------
@@ -214,15 +292,15 @@ const SELECTION_EDITOR_IN = makeEditor(Input, ({ value, update }) => ({
const SELECTION = {
operators: ["equal", "not_equal", "in", "not_in", "set", "not_set"],
editors: {
default: makeEditor(Select, ({ value, update, field }) => ({
default: makeEditor(Select, ({ value, update, fieldDef }) => ({
value,
update,
options: field.selection,
options: fieldDef.selection || [],
})),
in: SELECTION_EDITOR_IN,
not_in: SELECTION_EDITOR_IN,
},
defaultValue: (field) => field.selection[0][0] ?? false,
defaultValue: (fieldDef) => fieldDef.selection[0][0] ?? false,
};
// ----------------------------------------------------------------------------
@@ -235,19 +313,19 @@ const PROPERTIES = {
const PROPERTIES_SELECTION = {
operators: ["equal", "not_equal", "set", "not_set"],
editors: {
default: makeEditor(Select, ({ value, update, field }) => ({
default: makeEditor(Select, ({ value, update, fieldDef }) => ({
value,
update,
options: field.selection || [],
options: fieldDef.selection || [],
})),
},
defaultValue: (field) => field.selection?.[0]?.[0] ?? false,
defaultValue: ({ selection }) => selection?.[0]?.[0] ?? false,
};
const PROPERTIES_RELATIONAL = {
operators: ["equal", "not_equal", "set", "not_set"],
editors: { default: makeEditor(Input) },
defaultValue: (field) => (field.type === "many2one" ? 1 : []),
defaultValue: (fieldDef) => (fieldDef.type === "many2one" ? 1 : []),
};
// ============================================================================
@@ -279,25 +357,32 @@ const PROPERTIES_DESCRIPTIONS = {
};
export function getFieldInfo(fieldDef) {
if (fieldDef.is_property && PROPERTIES_DESCRIPTIONS[fieldDef.type]) {
return PROPERTIES_DESCRIPTIONS[fieldDef.type];
} else if (FIELD_DESCRIPTIONS[fieldDef.type]) {
return FIELD_DESCRIPTIONS[fieldDef.type];
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 fieldInfo = getFieldInfo(fieldDef);
return fieldInfo.editors[operator] || fieldInfo.editors.default;
export function getEditorInfo(fieldDef, operatorKey) {
const descr = getFieldInfo(fieldDef);
return descr.editors[operatorKey] || descr.editors.default;
}
export function getOperatorsInfo(fieldDef) {
return selectOperators(getFieldInfo(fieldDef).operators);
const descr = getFieldInfo(fieldDef);
return selectOperators(descr.operators);
}
export function getDefaultFieldValue(fieldDef) {
const desc = getFieldInfo(fieldDef);
return desc.defaultValue(fieldDef);
const descr = getFieldInfo(fieldDef);
return descr.defaultValue(fieldDef);
}
export function getDefaultOperator(fieldDef) {
const [firstOperator] = getOperatorsInfo(fieldDef);
return firstOperator.symbol;
}
@@ -1,6 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelector.Editor" owl="1">
<t t-if="isExprValue">
<div class="o_ds_expr_value o_input d-flex align-items-center p-0" data-tooltip="Expressions can not be edited">
<span class="ms-2" t-esc="props.value.expr" />
<i role="alert" class="ms-auto fa fa-exclamation-triangle text-warning" title="Expression" />
<button class="btn btn-link p-1" title="Clear" t-on-click="() => this.props.onExprRemove()">
<i class="fa fa-times" />
</button>
</div>
</t>
<t t-else="">
<t t-component="props.info.component" t-props="props.info.extractProps({ value: props.value, update: props.update, fieldDef: props.fieldDef })" />
</t>
</t>
<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>
@@ -17,7 +32,7 @@
<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" />
<t t-esc="getTagValue(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>
@@ -28,4 +43,12 @@
</div>
</t>
<t t-name="web.DomainSelector.Range" owl="1">
<div class="d-flex h-100 align-items-center">
<Editor info="editorInfo" value="getValue(0)" update="getUpdater(0)" onExprRemove="getExprRemover(0)" />
<i class="fa fa-long-arrow-right mx-2" aria-label="Arrow icon" title="Arrow" />
<Editor info="editorInfo" value="getValue(1)" update="getUpdater(1)" onExprRemove="getExprRemover(1)" />
</div>
</t>
</templates>
@@ -1,13 +1,25 @@
/** @odoo-module **/
import { _lt, _t } from "@web/core/l10n/translation";
import { Domain } from "@web/core/domain";
import { findOperator, parseOperator } from "./domain_selector_operators";
import { formatAST, toPyValue } from "@web/core/py_js/py_utils";
import { sprintf } from "@web/core/utils/strings";
export class DomainValueExpr {
constructor(ast) {
this.ast = ast;
this.expr = formatAST(ast);
}
}
class DomainNode {
static nextId = 0;
constructor(type) {
constructor(type, negate) {
this.id = ++DomainNode.nextId;
this.type = type;
this.negate = negate;
}
toDomain() {
@@ -17,18 +29,37 @@ class DomainNode {
// ----------------------------------------------------------------------------
const TITLES = {
AND: {
false: _lt("all"),
true: _lt("not all"),
},
OR: {
false: _lt("any"),
true: _lt("none"),
},
};
export class BranchDomainNode extends DomainNode {
constructor(operator, children = []) {
super("branch");
this.operator = operator;
constructor(connector, children = [], negate = false) {
super("branch", negate);
this.connector = connector;
this.children = children;
}
get title() {
return TITLES[this.connector][this.negate];
}
toDomain() {
return Domain.combine(
let domain = Domain.combine(
this.children.map((c) => c.toDomain()),
this.operator
this.connector
);
if (this.negate) {
domain = Domain.not(domain);
}
return domain;
}
add(node) {
@@ -49,18 +80,112 @@ export class BranchDomainNode extends DomainNode {
// ----------------------------------------------------------------------------
export class LeafDomainNode extends DomainNode {
constructor(field, operator, value) {
super("leaf");
this.field = field;
this.operator = operator;
constructor(pathInfo, operatorInfo, value, negate = false) {
super("leaf", negate);
this.pathInfo = pathInfo;
this.operatorInfo = operatorInfo;
this.value = value;
}
formatValue(value) {
if (value instanceof DomainValueExpr) {
return value.expr;
} else if (Array.isArray(value)) {
return `[${value.map((v) => this.formatValue(v)).join(", ")}]`;
} else {
return formatAST(toPyValue(value));
}
}
toDomain() {
return new Domain([[this.field.name, this.operator.symbol, this.value]]);
const value = this.formatValue(this.value);
let domain = new Domain(
`[("${this.pathInfo.path}", "${this.operatorInfo.symbol}", ${value})]`
);
if (this.negate) {
domain = Domain.not(domain);
}
return domain;
}
clone() {
return new LeafDomainNode(this.field, this.operator, this.value);
return new LeafDomainNode(this.pathInfo, this.operatorInfo, this.value, this.negate);
}
}
// ----------------------------------------------------------------------------
/**
* @param {import("@web/core/py_js/py_parser").AST} ast
* @returns {any}
*/
export function getValue(ast) {
if ([4, 10].includes(ast.type)) {
return ast.value.map((v) => getValue(v));
} else if ([0, 1, 2, 3].includes(ast.type)) {
return ast.value;
} else if (ast.type === 6 && ast.op === "-" && ast.right.type === 0) {
return -ast.right.value;
} else {
return new DomainValueExpr(ast);
}
}
/**
* @param {import("@web/core/domain_tree").Tree} tree
* @param {Object} fieldDef
* @returns
*/
export function getLeafOperatorInfo(tree, fieldDef) {
const { type: fieldType } = fieldDef || {};
const rawOperator = tree.operator;
const negate = tree.negate;
const isInvalid = typeof rawOperator !== "string" || !parseOperator(rawOperator);
let operatorInfo;
if (isInvalid) {
// symbol not defined
const operatorInfo = {
isInvalid: true,
key: rawOperator,
label: rawOperator,
symbol: rawOperator,
};
if (negate) {
operatorInfo.label = sprintf(_t(`not %s`), rawOperator);
}
return operatorInfo;
}
if (fieldType && fieldType === "boolean") {
operatorInfo = findOperator(rawOperator === "=" ? "is" : "is_not");
} else if (tree.valueAST.type === 2) {
operatorInfo = findOperator(rawOperator === "!=" ? "set" : "not_set");
} else {
operatorInfo = parseOperator(rawOperator);
}
if (negate) {
operatorInfo = { ...operatorInfo, label: sprintf(_t(`not %s`), rawOperator) };
}
return operatorInfo;
}
/**
* @param {import("@web/core/domain_tree").Tree} tree
* @param {Object} pathsInfo
* @returns {LeafDomainNode|BranchDomainNode}
*/
export function toDomainSelectorTree(tree, pathsInfo) {
if (tree.type === "condition") {
const path = tree.path;
const negate = tree.negate;
const pathInfo = pathsInfo[path];
const operatorInfo = getLeafOperatorInfo(tree, pathInfo.fieldDef);
const value = getValue(tree.valueAST);
return new LeafDomainNode(pathInfo, operatorInfo, value, negate);
}
const connector = tree.value === "&" ? "AND" : "OR";
const negate = tree.negate;
const children = tree.children.map((child) => toDomainSelectorTree(child, pathsInfo));
return new BranchDomainNode(connector, children, negate);
}
@@ -3,26 +3,33 @@
import { _lt } from "@web/core/l10n/translation";
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" },
// valid operators (see TERM_OPERATORS in expression.py)
{ key: "equal", label: "=", symbol: "=", valueCount: 1 },
{ key: "not_equal", label: "!=", symbol: "!=", valueCount: 1 },
{ key: "less_equal", label: "<=", symbol: "<=", valueCount: 1 },
{ key: "less_than", label: "<", symbol: "<", valueCount: 1 },
{ key: "greater_than", label: ">", symbol: ">", valueCount: 1 },
{ key: "greater_equal", label: ">=", symbol: ">=", valueCount: 1 },
{ key: "equal_question", label: "=?", symbol: "=?", valueCount: 1 },
{ key: "equal_like", label: _lt("=like"), symbol: "=like", valueCount: 1 },
{ key: "equal_ilike", label: _lt("=ilike"), symbol: "=ilike", valueCount: 1 },
{ key: "like", label: _lt("like"), symbol: "like", valueCount: 1 },
{ key: "not_like", label: _lt("not like"), symbol: "not like", valueCount: 1 },
{ key: "ilike", label: _lt("contains"), symbol: "ilike", valueCount: 1 },
{ key: "not_ilike", label: _lt("does not contain"), symbol: "not ilike", valueCount: 1 },
{ key: "in", label: _lt("in"), symbol: "in", valueCount: "variable" },
{ key: "not_in", label: _lt("not in"), symbol: "not in", valueCount: "variable" },
{ key: "child_of", label: _lt("child of"), symbol: "child_of", valueCount: 1 },
{ key: "parent_of", label: _lt("parent of"), symbol: "parent_of", valueCount: 1 },
// virtual operators (replace = and != in some cases)
{ key: "is", label: _lt("is"), symbol: "=", valueCount: 1 },
{ key: "is_not", label: _lt("is not"), symbol: "!=", valueCount: 1 },
{ key: "set", label: _lt("is set"), symbol: "!=", valueCount: 0 },
{ key: "not_set", label: _lt("is not set"), symbol: "=", valueCount: 0 },
// virtual operator (equivalent to a couple (>=,<=))
{ key: "between", label: _lt("is between"), symbol: "between", valueCount: 2 },
];
export function findOperator(key) {
@@ -1,106 +0,0 @@
/** @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) {
const valueAst = rawNode.value[2];
if (valueAst.type === 6 && valueAst.op === "-" && valueAst.right.type === 0) {
return -valueAst.right.value;
}
return valueAst.value;
}
/** @private */
isLeafValueArray(rawNode) {
return [4, 10].includes(rawNode.value[2].type);
}
}
@@ -0,0 +1,65 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
import { getDefaultFieldValue, getDefaultOperator } from "./domain_selector_fields";
import { toDomain, toTree } from "@web/core/domain_tree";
import { toDomainSelectorTree } from "@web/core/domain_selector/domain_selector_nodes";
import { useService } from "@web/core/utils/hooks";
/**
* @param {BranchDomainNode} domainSelectorTree
* @returns {string}
*/
export function buildDomain(domainSelectorTree) {
const tree = toTree(domainSelectorTree.toDomain());
return toDomain(tree);
}
/**
* @param {import("@web/core/domain").DomainRepr} domain
* @param {Object} pathsInfo
* @param {Object} options
* @returns {BranchDomainNode}
*/
export function buildDomainSelectorTree(domain, pathsInfo, options = {}) {
const tree = toTree(domain, options);
return toDomainSelectorTree(tree, pathsInfo);
}
/**
* @param {import("@web/core/domain").DomainRepr} domain
* @returns {any[]}
*/
export function extractPathsFromDomain(domain) {
domain = new Domain(domain);
const paths = new Set();
for (const node of domain.ast.value) {
if ([4, 10].includes(node.type)) {
paths.add(node.value[0].value);
}
}
return [...paths];
}
const SPECIAL_FIELDS = ["country_id", "user_id", "partner_id", "stage_id"];
function getDefaultDomain(fieldDefs) {
let fieldDef = null;
for (const name of SPECIAL_FIELDS) {
fieldDef = fieldDefs[name];
if (fieldDef) {
const operator = getDefaultOperator(fieldDef);
const defaultValue = getDefaultFieldValue(fieldDef);
return new Domain([[fieldDef.name, operator, defaultValue]]).toString();
}
}
return new Domain([["id", "=", 1]]).toString();
}
export function useGetDefaultLeafDomain() {
const fieldService = useService("field");
return async (resModel) => {
const fieldDefs = await fieldService.loadFields(resModel);
return getDefaultDomain(fieldDefs);
};
}
@@ -3,17 +3,34 @@
import { _t } from "../l10n/translation";
import { Component, useState } from "@odoo/owl";
import { Dialog } from "../dialog/dialog";
import { Domain } from "@web/core/domain";
import { DomainSelector } from "../domain_selector/domain_selector";
import { useService } from "../utils/hooks";
export class DomainSelectorDialog extends Component {
setup() {
this.state = useState({
domain: this.props.domain,
});
this.notification = useService("notification");
this.orm = useService("orm");
this.state = useState({ domain: this.props.domain });
}
get confirmButtonText() {
return this.props.confirmButtonText || _t("Confirm");
}
get dialogTitle() {
return _t("Domain");
return this.props.title || _t("Domain");
}
get disabled() {
if (this.props.disableConfirmButton) {
return this.props.disableConfirmButton(this.state.domain);
}
return false;
}
get discardButtonText() {
return this.props.discardButtonText || _t("Discard");
}
get domainSelectorProps() {
@@ -22,8 +39,9 @@ export class DomainSelectorDialog extends Component {
resModel: this.props.resModel,
readonly: this.props.readonly,
isDebugMode: this.props.isDebugMode,
defaultConnector: this.props.defaultConnector,
defaultLeafValue: this.props.defaultLeafValue,
value: this.state.domain,
domain: this.state.domain,
update: (domain) => {
this.state.domain = domain;
},
@@ -31,9 +49,20 @@ export class DomainSelectorDialog extends Component {
}
async onConfirm() {
await this.props.onConfirm(this.state.domain);
try {
let domain = new Domain(this.state.domain);
domain = domain.toList(this.props.context);
await this.orm.silent.searchCount(this.props.resModel, domain, { limit: 1 });
} catch {
this.notification.add(this.env._t("Domain is invalid. Please correct it"), {
type: "danger",
});
return;
}
this.props.onConfirm(this.state.domain);
this.props.close();
}
onDiscard() {
this.props.close();
}
@@ -48,14 +77,20 @@ DomainSelectorDialog.props = {
onConfirm: Function,
resModel: String,
className: { type: String, optional: true },
defaultConnector: { type: [{ value: "&" }, { value: "|" }], optional: true },
defaultLeafValue: { type: Array, optional: true },
domain: { type: String, optional: true },
domain: String,
isDebugMode: { type: Boolean, optional: true },
readonly: { type: Boolean, optional: true },
text: { type: String, optional: true },
confirmButtonText: { type: String, optional: true },
disableConfirmButton: { type: Function, optional: true },
discardButtonText: { type: String, optional: true },
title: { type: String, optional: true },
context: { type: Object, optional: true },
};
DomainSelectorDialog.defaultProps = {
domain: "[]",
isDebugMode: false,
readonly: false,
context: {},
};
@@ -10,8 +10,8 @@
<button class="btn btn-secondary" t-on-click="() => props.close()">Close</button>
</t>
<t t-else="">
<button class="btn btn-primary" t-on-click="onConfirm">Confirm</button>
<button class="btn btn-secondary" t-on-click="onDiscard">Discard</button>
<button class="btn btn-primary" t-att-disabled="disabled" t-on-click="onConfirm"><t t-esc="confirmButtonText"/></button>
<button class="btn btn-secondary" t-on-click="onDiscard"><t t-esc="discardButtonText"/></button>
</t>
</t>
</Dialog>
+256
View File
@@ -0,0 +1,256 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
import { formatAST, toPyValue } from "@web/core/py_js/py_utils";
const TERM_OPERATORS_NEGATION = {
"<": ">=",
">": "<=",
"<=": ">",
">=": "<",
"=": "!=",
"!=": "=",
in: "not in",
like: "not like",
ilike: "not ilike",
"not in": "in",
"not like": "like",
"not ilike": "ilike",
};
/** @typedef { import("@web/core/py_js/py_parser").AST } AST */
/** @typedef {import("@web/core/domain").DomainRepr} DomainRepr */
/**
* @typedef {Object} Condition
* @property {"condition"} type
* @property {string|1|0} path
* @property {string} operator
* @property {AST} valueAST
* @property {boolean} negate // used in case operator has no negation defined
*/
/**
* @typedef {Object} AND
* @property {"connector"} type
* @property {boolean} negate
* @property {"&"} value
* @property {(Connector|OR)[]} children
*/
/**
* @typedef {Object} OR
* @property {"connector"} type
* @property {boolean} negate
* @property {"|"} value
* @property {(Connector|AND)[]} children
*/
/**
* @typedef {AND|OR|Condition} Tree
*/
/**
* @param {AST[]} ASTs
* @param {boolean} distributeNot
* @param {boolean} [negate=false]
* @returns {{ tree: Tree, remaimingASTs: AST[] }}
*/
function _construcTree(ASTs, distributeNot, negate = false) {
const [firstAST, ...tailASTs] = ASTs;
if (firstAST.type === 1 && firstAST.value === "!") {
return _construcTree(tailASTs, distributeNot, !negate);
}
const tree = { type: firstAST.type === 1 ? "connector" : "condition" };
if (tree.type === "connector") {
tree.value = firstAST.value;
if (distributeNot && negate) {
tree.value = tree.value === "&" ? "|" : "&";
tree.negate = false;
} else {
tree.negate = negate;
}
tree.children = [];
} else {
const [pathAST, operatorAST, valueAST] = firstAST.value;
tree.path = pathAST.value;
if (negate && TERM_OPERATORS_NEGATION[operatorAST.value]) {
tree.operator = TERM_OPERATORS_NEGATION[operatorAST.value];
} else {
tree.operator = operatorAST.value;
tree.negate = negate;
}
tree.valueAST = valueAST;
}
let remaimingASTs = tailASTs;
if (tree.type === "connector") {
for (let i = 0; i < 2; i++) {
const { tree: child, remaimingASTs: otherASTs } = _construcTree(
remaimingASTs,
distributeNot,
distributeNot && negate
);
remaimingASTs = otherASTs;
if (child.type === "connector" && !child.negate && child.value === tree.value) {
tree.children.push(...child.children);
} else {
tree.children.push(child);
}
}
}
return { tree, remaimingASTs };
}
/**
* @param {AST[]} initialASTs
* @param {"&"|"|"} [defaultConnector="&"]
* @param {boolean} [distributeNot=false]
* @returns {Tree}
*/
function construcTree(initialASTs, options) {
const value = options.defaultConnector || "&";
if (!initialASTs.length) {
return { type: "connector", value, negate: false, children: [] };
}
const { tree } = _construcTree(initialASTs, options.distributeNot);
if (tree.type === "condition") {
return { type: "connector", value, negate: false, children: [tree] };
}
return tree;
}
/**
* @param {Tree} tree
* @returns {AST[]}
*/
function getASTs(tree) {
if (tree.type === "condition") {
return [
{
type: 10,
value: [toPyValue(tree.path), toPyValue(tree.operator), tree.valueAST],
},
];
}
const ASTs = [];
const length = tree.children.length;
if (length && tree.negate) {
ASTs.push(toPyValue("!"));
}
for (let i = 0; i < length - 1; i++) {
ASTs.push(toPyValue(tree.value));
}
for (const child of tree.children) {
ASTs.push(...getASTs(child));
}
return ASTs;
}
/**
* @param {Tree} tree
* @param {boolean} [isRoot=true]
* @returns {Tree}
*/
function createBetweenOperators(tree, isRoot = true) {
if (tree.type === "condition") {
return tree;
}
const processedChildren = tree.children.map((c) => createBetweenOperators(c, false));
if (tree.value === "|") {
return { ...tree, children: processedChildren };
}
const children = [];
for (let i = 0; i < processedChildren.length; i++) {
const child1 = processedChildren[i];
const child2 = processedChildren[i + 1];
if (
child1.type === "condition" &&
child2 &&
child2.type === "condition" &&
child1.path === child2.path &&
child1.operator === ">=" &&
child2.operator === "<="
) {
children.push({
type: "condition",
path: child1.path,
operator: "between",
valueAST: {
type: 4,
value: [child1.valueAST, child2.valueAST],
},
});
i += 1;
} else {
children.push(child1);
}
}
if (children.length === 1 && !isRoot) {
return { ...children[0] };
}
return { ...tree, children };
}
/**
* @param {Tree} tree
* @returns {Tree}
*/
function removeBetweenOperators(tree) {
if (tree.type === "condition") {
if (tree.operator !== "between") {
return tree;
}
const { path, valueAST } = tree;
return {
type: "connector",
value: "&",
children: [
{ type: "condition", path, operator: ">=", valueAST: valueAST.value[0] },
{ type: "condition", path, operator: "<=", valueAST: valueAST.value[1] },
],
};
}
const processedChildren = tree.children.map((c) => removeBetweenOperators(c));
if (tree.value === "|") {
return { ...tree, children: processedChildren };
}
const children = [];
// after processing a child might have become a connector "&" --> normalize
for (let i = 0; i < processedChildren.length; i++) {
const child = processedChildren[i];
if (child.type === "connector" && !child.negate && child.value === "&") {
children.push(...child.children);
} else {
children.push(child);
}
}
return { ...tree, children };
}
/**
* @param {Tree} tree
* @returns {string} a string representation of a domain
*/
export function toDomain(tree) {
const simplifiedTree = removeBetweenOperators(tree);
const domainAST = {
type: 4,
value: getASTs(simplifiedTree),
};
return formatAST(domainAST);
}
/**
* @param {DomainRepr} domain
* @param {Object} [options={}] see construcTree API
* @returns {Tree} a tree representation of a domain
*/
export function toTree(domain, options = {}) {
domain = new Domain(domain);
const domainAST = domain.ast;
const tree = construcTree(domainAST.value, options);
return createBetweenOperators(tree);
}
@@ -3,25 +3,8 @@
import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
import { KeepLast } from "@web/core/utils/concurrency";
import { ModelFieldSelectorPopover } from "./model_field_selector_popover";
import { useLoadFieldInfo, useLoadPathDescription } from "./utils";
import { usePopover } from "@web/core/popover/popover_hook";
import { useService } from "@web/core/utils/hooks";
export async function loadFieldInfo(fieldService, resModel, path) {
if (typeof path !== "string" || !path) {
return { resModel, fieldDef: null };
}
const { isInvalid, names, modelsInfo } = await fieldService.loadPath(resModel, path);
if (isInvalid) {
return { resModel, fieldDef: null };
}
const name = names.at(-1);
const modelInfo = modelsInfo.at(-1);
return { resModel: modelInfo.resModel, fieldDef: modelInfo.fieldDefs[name] };
}
function makeString(value) {
return String(value ?? "-");
}
export class ModelFieldSelector extends Component {
static template = "web._ModelFieldSelector";
@@ -47,61 +30,21 @@ export class ModelFieldSelector extends Component {
};
setup() {
this.loadPathDescription = useLoadPathDescription();
const loadFieldInfo = useLoadFieldInfo();
this.popover = usePopover(this.constructor.components.Popover, {
popoverClass: "o_popover_field_selector",
onClose: async () => {
if (this.newPath) {
const fieldInfo = await loadFieldInfo(
this.fieldService,
this.props.resModel,
this.newPath
);
const fieldInfo = await loadFieldInfo(this.props.resModel, this.newPath);
this.props.update(this.newPath, fieldInfo);
}
},
});
this.keepLast = new KeepLast();
this.fieldService = useService("field");
this.state = useState({
isInvalid: false,
displayNames: [],
});
onWillStart(() => this.updatePath(this.props.resModel, this.props.path));
onWillUpdateProps((nextProps) => this.updatePath(nextProps.resModel, nextProps.path));
}
async updatePath(resModel, path, isConcurrent) {
let prom = this.loadPath(resModel, path);
if (isConcurrent) {
prom = this.keepLast.add(prom);
}
const state = await prom;
Object.assign(this.state, state);
}
async loadPath(resModel, path) {
// the model should be checked maybe
if ([0, 1].includes(path)) {
return { isInvalid: false, displayNames: [makeString(path)] };
}
if (typeof path !== "string" || !path) {
return { isInvalid: true, displayNames: [makeString()] };
}
const { isInvalid, modelsInfo, names } = await this.fieldService.loadPath(resModel, path);
const result = { isInvalid: !!isInvalid, displayNames: [] };
if (!isInvalid) {
const lastFieldDef = modelsInfo.at(-1).fieldDefs[names.at(-1)];
if (["properties", "properties_definition"].includes(lastFieldDef.type)) {
// there is no known case where we want to select a 'properties' field directly
result.isInvalid = true;
}
}
for (let index = 0; index < names.length; index++) {
const name = names[index];
const fieldDef = modelsInfo[index]?.fieldDefs[name];
result.displayNames.push(fieldDef?.string || makeString(name));
}
return result;
this.state = useState({ isInvalid: false, displayNames: [] });
onWillStart(() => this.updateState(this.props.resModel, this.props.path));
onWillUpdateProps((nextProps) => this.updateState(nextProps.resModel, nextProps.path));
}
openPopover(currentTarget) {
@@ -114,7 +57,7 @@ export class ModelFieldSelector extends Component {
path: this.props.path,
update: (path) => {
this.newPath = path;
this.updatePath(this.props.resModel, path, true);
this.updateState(this.props.resModel, path, true);
},
showSearchInput: this.props.showSearchInput,
isDebugMode: this.props.isDebugMode,
@@ -122,4 +65,13 @@ export class ModelFieldSelector extends Component {
followRelations: this.props.followRelations,
});
}
async updateState(resModel, path, isConcurrent) {
let prom = this.loadPathDescription(resModel, path);
if (isConcurrent) {
prom = this.keepLast.add(prom);
}
const state = await prom;
Object.assign(this.state, state);
}
}
@@ -0,0 +1,51 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
function makeString(value) {
return String(value ?? "-");
}
export function useLoadFieldInfo(fieldService) {
fieldService ||= useService("field");
return async (resModel, path) => {
if (typeof path !== "string" || !path) {
return { resModel, fieldDef: null };
}
const { isInvalid, names, modelsInfo } = await fieldService.loadPath(resModel, path);
if (isInvalid) {
return { resModel, fieldDef: null };
}
const name = names.at(-1);
const modelInfo = modelsInfo.at(-1);
return { resModel: modelInfo.resModel, fieldDef: modelInfo.fieldDefs[name] };
};
}
export function useLoadPathDescription(fieldService) {
fieldService ||= useService("field");
return async (resModel, path) => {
if ([0, 1].includes(path)) {
return { isInvalid: false, displayNames: [makeString(path)] };
}
if (typeof path !== "string" || !path) {
return { isInvalid: true, displayNames: [makeString()] };
}
const { isInvalid, modelsInfo, names } = await fieldService.loadPath(resModel, path);
const result = { isInvalid: !!isInvalid, displayNames: [] };
if (!isInvalid) {
const lastName = names.at(-1);
const lastFieldDef = modelsInfo.at(-1).fieldDefs[lastName];
if (["properties", "properties_definition"].includes(lastFieldDef.type)) {
// there is no known case where we want to select a 'properties' field directly
result.isInvalid = true;
}
}
for (let index = 0; index < names.length; index++) {
const name = names[index];
const fieldDef = modelsInfo[index]?.fieldDefs[name];
result.displayNames.push(fieldDef?.string || makeString(name));
}
return result;
};
}
@@ -1,18 +0,0 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { DomainSelectorDialog } from "@web/core/domain_selector_dialog/domain_selector_dialog";
export class AdvancedSearchDialog extends DomainSelectorDialog {
get dialogTitle() {
return _t("Advanced Search");
}
async onConfirm() {
const confirmed = await this.props.onConfirm(this.state.domain);
if (confirmed) {
this.props.close();
}
}
}
AdvancedSearchDialog.template = "web.AdvancedSearchDialog";
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.AdvancedSearchDialog" t-inherit="web.DomainSelectorDialog" owl="1">
<xpath expr="//t[@t-else='']/button[hasclass('btn-primary')]" position="replace">
<button class="btn btn-primary" t-on-click="onConfirm">Search</button>
</xpath>
</t>
</templates>
@@ -1,19 +1,20 @@
/** @odoo-module **/
import { AdvancedSearchDialog } from "./advanced_search_dialog";
import { Component } from "@odoo/owl";
import { Domain } from "@web/core/domain";
import { DomainSelectorDialog } from "../../core/domain_selector_dialog/domain_selector_dialog";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item";
import { FACET_ICONS } from "../utils/misc";
import { useBus, useService } from "@web/core/utils/hooks";
import { useGetDefaultLeafDomain } from "@web/core/domain_selector/utils";
import { _t } from "@web/core/l10n/translation";
export class FilterMenu extends Component {
setup() {
this.icon = FACET_ICONS.filter;
this.dialogService = useService("dialog");
this.notification = useService("notification");
this.getDefaultLeafDomain = useGetDefaultLeafDomain();
useBus(this.env.searchModel, "update", this.render);
}
@@ -26,17 +27,19 @@ export class FilterMenu extends Component {
);
}
onAdvancedSearchClick() {
const domains = [this.env.searchModel._getDomain({ withGlobal: false })];
if (this.env.searchModel.comparison && !this.env.searchModel.globalComparison) {
const { range } = this.env.searchModel.getFullComparison();
domains.push(range);
}
const domain = Domain.and(domains).toString();
this.dialogService.add(AdvancedSearchDialog, {
async onAddCustomFilterClick() {
const { domainEvalContext: context, resModel } = this.env.searchModel;
const domain = await this.getDefaultLeafDomain(resModel);
this.dialogService.add(DomainSelectorDialog, {
resModel,
defaultConnector: "|",
domain,
onConfirm: (domain) => this.onConfirm(domain),
resModel: this.env.searchModel.resModel,
context,
onConfirm: (domain) => this.env.searchModel.splitAndAddDomain(domain),
disableConfirmButton: (domain) => domain === `[]`,
title: _t("Add Custom Filter"),
confirmButtonText: _t("Add"),
discardButtonText: _t("Cancel"),
isDebugMode: !!this.env.debug,
});
}
@@ -53,18 +56,6 @@ export class FilterMenu extends Component {
this.env.searchModel.toggleSearchItem(itemId);
}
}
async onConfirm(domain) {
const isValid = await this.env.searchModel.isValidDomain(domain);
if (!isValid) {
this.notification.add(this.env._t("Domain is invalid. Please correct it"), {
type: "danger",
});
return false;
}
this.env.searchModel.createAdvancedDomain(domain);
return true;
}
}
FilterMenu.components = { Dropdown, DropdownItem, SearchDropdownItem };
@@ -45,7 +45,7 @@
<t t-if="items.length">
<div role="separator" class="dropdown-divider"/>
</t>
<DropdownItem onSelected.bind="onAdvancedSearchClick">Advanced Search</DropdownItem>
<DropdownItem onSelected.bind="onAddCustomFilterClick">Add Custom Filter</DropdownItem>
</Dropdown>
</t>
@@ -5,7 +5,9 @@ import { serializeDate, serializeDateTime } from "@web/core/l10n/dates";
import { registry } from "@web/core/registry";
import { KeepLast } from "@web/core/utils/concurrency";
import { useAutofocus, useBus, useService } from "@web/core/utils/hooks";
import { DomainSelectorDialog } from "@web/core/domain_selector_dialog/domain_selector_dialog";
import { fuzzyTest } from "@web/core/utils/search";
import { _t } from "@web/core/l10n/translation";
import { Component, useExternalListener, useRef, useState } from "@odoo/owl";
const parsers = registry.category("parsers");
@@ -17,6 +19,7 @@ let nextItemId = 1;
export class SearchBar extends Component {
setup() {
this.dialogService = useService("dialog");
this.fields = this.env.searchModel.searchViewFields;
this.searchItemsFields = this.env.searchModel.getSearchItems((f) => f.type === "field");
this.root = useRef("root");
@@ -360,6 +363,23 @@ export class SearchBar extends Component {
// Handlers
//---------------------------------------------------------------------
onFacetClick(target, facet) {
const { domain, groupId } = facet;
if (!target.classList.contains("o_searchview_facet_label") || !domain) {
return;
}
const { resModel } = this.env.searchModel;
this.dialogService.add(DomainSelectorDialog, {
resModel,
domain,
context: this.env.searchModel.domainEvalContext,
onConfirm: (domain) => this.env.searchModel.splitAndAddDomain(domain, groupId),
disableConfirmButton: (domain) => domain === `[]`,
title: _t("Modify Condition"),
isDebugMode: !!this.env.debug,
});
}
/**
* @param {Object} facet
* @param {number} facetIndex
@@ -2,6 +2,7 @@
// ============================================================================
.o_searchview_facet {
cursor: default;
.o_facet_values {
border: $border-width solid var(--SearchBar-facet-background, #{$o-brand-odoo});
}
@@ -9,4 +10,8 @@
background: var(--SearchBar-facet-background, #{$o-brand-odoo});
color: var(--SearchBar-facet-color, #{$o-white});
}
&.o_facet_with_domain:hover .o_searchview_facet_label {
cursor: pointer;
background: var(--SearchBar-facet-background, $primary);
}
}
@@ -4,10 +4,12 @@
<t t-name="web.SearchBar.Facets" owl="1">
<t t-foreach="env.searchModel.facets" t-as="facet" t-key="facet_index">
<div class="o_searchview_facet"
t-att-class="{o_facet_with_domain: facet.domain }"
role="img"
aria-label="search"
tabindex="0"
t-on-keydown="ev => this.onFacetKeydown(facet, facet_index, ev)"
t-on-click="(ev) => this.onFacetClick(ev.target, facet)"
>
<t t-if="facet.icon">
<span t-attf-class="o_searchview_facet_label {{ facet.icon }}"/>
+180 -68
View File
@@ -16,8 +16,17 @@ import {
yearSelected,
} from "./utils/dates";
import { FACET_ICONS } from "./utils/misc";
import { EventBus, toRaw } from "@odoo/owl";
import { toDomain, toTree } from "@web/core/domain_tree";
import { extractPathsFromDomain } from "@web/core/domain_selector/utils";
import { useLoadFieldInfo, useLoadPathDescription } from "@web/core/model_field_selector/utils";
import {
DomainValueExpr,
getLeafOperatorInfo,
getValue,
} from "@web/core/domain_selector/domain_selector_nodes";
import { _t } from "@web/core/l10n/translation";
const { DateTime } = luxon;
/** @typedef {import("@web/core/domain").DomainRepr} DomainRepr */
@@ -146,6 +155,94 @@ function execute(op, source, target) {
}
}
/**
* @param {Tree} tree
* @param {Object} pathsInfo
* @returns {string}
*/
export function getDomainTreeDescription(
tree,
pathFieldDefs,
pathDescriptions,
isSubExpression = true
) {
if (tree.type === "connector") {
// we assume that the domain tree is normalized (--> there is at least two children)
const childDescriptions = tree.children.map((c) =>
getDomainTreeDescription(c, pathFieldDefs, pathDescriptions)
);
const separator = tree.value === "&" ? _t("and") : _t("or");
let description = childDescriptions.join(` ${separator} `);
if (isSubExpression || tree.negate) {
description = `( ${description} )`;
}
if (tree.negate) {
description = `! ${description}`;
}
return description;
}
const { path, valueAST } = tree;
const fieldDef = pathFieldDefs[path];
const operatorInfo = getLeafOperatorInfo(tree, fieldDef);
let description = pathDescriptions[path];
if (valueAST.type === 2 /** boolean */) {
const value = valueAST.value;
description += ` is`;
if (
value
? ["is_not", "not_equal"].includes(operatorInfo.key)
: ["is", "equal"].includes(operatorInfo.key)
) {
description += ` not`;
}
description += ` set`;
} else {
const value = getValue(valueAST);
description += ` ${operatorInfo.label} `;
const values = Array.isArray(value) ? value : [value];
const formatValue = (value) => {
if (typeof value === "string") {
return `"${value}"`;
} else if (value instanceof DomainValueExpr) {
return value.expr;
} else if (Array.isArray(value)) {
return `[${value.map((v) => formatValue(v)).join(", ")}]`;
} else {
return value;
}
};
const join = operatorInfo.key === "between" ? _t("and") : _t("or");
description += values.map((val) => formatValue(val)).join(` ${join} `);
}
return description;
}
export function useGetDomainTreeDescription(fieldService) {
const loadFieldInfo = useLoadFieldInfo(fieldService);
const loadPathDescription = useLoadPathDescription(fieldService);
return async (resModel, tree) => {
const domain = toDomain(tree);
const paths = extractPathsFromDomain(domain);
const promises = [];
const pathFieldDefs = {};
const pathDescriptions = {};
for (const path of paths) {
promises.push(
loadPathDescription(resModel, path).then(({ displayNames }) => {
pathDescriptions[path] = displayNames.join(" 🠒 ");
}),
loadFieldInfo(resModel, path).then(({ fieldDef }) => {
pathFieldDefs[path] = fieldDef;
})
);
}
await Promise.all(promises);
return getDomainTreeDescription(tree, pathFieldDefs, pathDescriptions, false);
};
}
//--------------------------------------------------------------------------
// Global constants/variables
//--------------------------------------------------------------------------
@@ -164,11 +261,13 @@ export class SearchModel extends EventBus {
*/
setup(services) {
// services
const { orm, user, view } = services;
const { field: fieldService, orm, user, view } = services;
this.orm = orm;
this.userService = user;
this.viewService = view;
this.getDomainTreeDescription = useGetDomainTreeDescription(fieldService);
// used to manage search items related to date/datetime fields
this.referenceMoment = DateTime.local();
this.comparisonOptions = getComparisonOptions();
@@ -539,51 +638,6 @@ export class SearchModel extends EventBus {
this._notify();
}
createAdvancedDomain(domain) {
this.blockNotification = true;
const groups = this._getGroups();
const context = this._getContext();
for (const group of groups) {
const firstSearchItem = this.searchItems[group.activeItems[0].searchItemId];
const { type } = firstSearchItem;
for (const activeItem of group.activeItems) {
if (type === "favorite") {
const activeItemGroupBys = this._getSearchItemGroupBys(activeItem);
for (const activeItemGroupBy of activeItemGroupBys) {
const [fieldName, interval] = activeItemGroupBy.split(":");
this.createNewGroupBy(fieldName, { interval, invisible: true });
}
const index = this.query.length - activeItemGroupBys.length;
this.query = [...this.query.slice(index), ...this.query.slice(0, index)];
}
}
if (
[
"comparison",
"field",
"field_property",
"filter",
"dateFilter",
"favorite",
].includes(type)
) {
this.deactivateGroup(group.id);
}
}
this.blockNotification = false;
this.createNewFilters([
{
description: `${this.env._t("Advanced Search")}`,
domain,
invisible: true,
type: "filter",
context,
},
]);
}
/**
* Create a new filter of type 'favorite' and activate it.
* A new group containing only that filter is created.
@@ -834,26 +888,69 @@ export class SearchModel extends EventBus {
return sections.sort((s1, s2) => s1.index - s2.index);
}
/**
* @param {DomainRepr} domain
* @returns {boolean}
*/
async isValidDomain(domain) {
try {
domain = new Domain(domain);
domain = domain.toList(this.domainEvalContext);
await this.orm.silent.searchRead(this.resModel, domain, ["id"], { limit: 1 });
// TODO: invent a python method that check domain
} catch {
return false;
}
return true;
}
search() {
this.trigger("update");
}
async splitAndAddDomain(domain, groupId) {
const group = groupId ? this._getGroups().find((g) => g.id === groupId) : null;
let context;
if (group) {
const contexts = [];
for (const activeItem of group.activeItems) {
const context = this._getSearchItemContext(activeItem);
if (context) {
contexts.push(context);
}
}
context = makeContext(contexts);
}
const tree = toTree(domain);
const trees = !tree.negate && tree.value === "&" ? tree.children : [tree];
const promises = trees.map(async (tree) => {
const description = await this.getDomainTreeDescription(this.resModel, tree);
const preFilter = {
description,
domain: toDomain(tree),
invisible: true,
type: "filter",
};
if (context) {
preFilter.context = context;
}
return preFilter;
});
const preFilters = await Promise.all(promises);
this.blockNotification = true;
if (group) {
const firstActiveItem = group.activeItems[0];
const firstSearchItem = this.searchItems[firstActiveItem.searchItemId];
const { type } = firstSearchItem;
if (type === "favorite") {
const activeItemGroupBys = this._getSearchItemGroupBys(firstActiveItem);
for (const activeItemGroupBy of activeItemGroupBys) {
const [fieldName, interval] = activeItemGroupBy.split(":");
this.createNewGroupBy(fieldName, { interval, invisible: true });
}
const index = this.query.length - activeItemGroupBys.length;
this.query = [...this.query.slice(index), ...this.query.slice(0, index)];
}
this.deactivateGroup(groupId);
}
for (const preFilter of preFilters) {
this.createNewFilters([preFilter]);
}
this.blockNotification = false;
this._notify();
}
/**
* Set the active value id of a given category.
* @param {number} sectionId
@@ -1589,10 +1686,17 @@ export class SearchModel extends EventBus {
const facets = [];
const groups = this._getGroups();
for (const group of groups) {
const groupActiveItemDomains = [];
const values = [];
let title;
let type;
for (const activeItem of group.activeItems) {
const domain = this._getSearchItemDomain(activeItem, {
withDateFilterDomain: true,
});
if (domain) {
groupActiveItemDomains.push(domain);
}
const searchItem = this.searchItems[activeItem.searchItemId];
switch (searchItem.type) {
case "field_property":
@@ -1635,7 +1739,7 @@ export class SearchModel extends EventBus {
}
const facet = {
groupId: group.id,
type: type,
type,
values,
separator: type === "groupBy" ? ">" : this.env._t("or"),
};
@@ -1644,6 +1748,9 @@ export class SearchModel extends EventBus {
} else {
facet.icon = FACET_ICONS[type];
}
if (groupActiveItemDomains.length) {
facet.domain = Domain.or(groupActiveItemDomains).toString();
}
facets.push(facet);
}
@@ -1999,8 +2106,10 @@ export class SearchModel extends EventBus {
/**
* Return the domain of the provided filter.
* @param {Object} [options={}]
* @param {boolean} [options.withDateFilterDomain]
*/
_getSearchItemDomain(activeItem) {
_getSearchItemDomain(activeItem, options = {}) {
const { searchItemId } = activeItem;
const searchItem = this.searchItems[searchItemId];
switch (searchItem.type) {
@@ -2010,10 +2119,13 @@ export class SearchModel extends EventBus {
}
case "dateFilter": {
const { dateFilterId } = this._getActiveComparison() || {};
if (this.searchMenuTypes.has("comparison") && dateFilterId === searchItemId) {
return new Domain([]);
if (
options.withDateFilterDomain ||
!(this.searchMenuTypes.has("comparison") && dateFilterId === searchItemId)
) {
return this._getDateFilterDomain(searchItem, activeItem.generatorIds);
}
return this._getDateFilterDomain(searchItem, activeItem.generatorIds);
return new Domain([]);
}
case "filter":
case "favorite": {
@@ -1,10 +1,9 @@
/** @odoo-module **/
import { useBus, useService } from "@web/core/utils/hooks";
import { SearchModel } from "@web/search/search_model";
import { CallbackRecorder, useSetupAction } from "@web/webclient/actions/action_hook";
import { Component, onWillStart, onWillUpdateProps, toRaw, useSubEnv } from "@odoo/owl";
import { SearchModel } from "@web/search/search_model";
import { useBus, useService } from "@web/core/utils/hooks";
export const SEARCH_KEYS = ["comparison", "context", "domain", "groupBy", "orderBy"];
@@ -24,6 +23,7 @@ export class WithSearch extends Component {
user: useService("user"),
orm: useService("orm"),
view: useService("view"),
field: useService("field"),
},
this.props.searchModelArgs
);
@@ -74,7 +74,6 @@ WithSearch.props = {
// search query elements
comparison: { type: [Object, { value: null }], optional: true },
// Issue OWL: https://github.com/odoo/owl/issues/910
context: { type: Object, optional: true },
domain: { type: Array, element: [String, Array], optional: true },
groupBy: { type: Array, element: String, optional: true },
@@ -1,15 +1,15 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
import { Domain, InvalidDomainError } from "@web/core/domain";
import { DomainSelector } from "@web/core/domain_selector/domain_selector";
import { DomainSelectorDialog } from "@web/core/domain_selector_dialog/domain_selector_dialog";
import { _lt } from "@web/core/l10n/translation";
import { EvaluationError } from "@web/core/py_js/py_interpreter";
import { registry } from "@web/core/registry";
import { useBus, useService, useOwnedDialogs } from "@web/core/utils/hooks";
import { Domain } from "@web/core/domain";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
import { standardFieldProps } from "../standard_field_props";
import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
import { useBus, useService, useOwnedDialogs } from "@web/core/utils/hooks";
export class DomainField extends Component {
static template = "web.DomainField";
@@ -28,119 +28,144 @@ export class DomainField extends Component {
setup() {
this.orm = useService("orm");
this.state = useState({
recordCount: null,
isValid: true,
});
this.addDialog = useOwnedDialogs();
this.displayedDomain = null;
this.isDebugEdited = false;
this.state = useState({
isValid: null,
recordCount: null,
});
this.isDebugEdited = false;
onWillStart(() => {
this.displayedDomain = this.props.record.data[this.props.name];
this.loadCount(this.props);
this.checkProps(); // not awaited
});
onWillUpdateProps((nextProps) => {
this.isDebugEdited = this.isDebugEdited && this.props.readonly === nextProps.readonly;
if (!this.isDebugEdited) {
this.displayedDomain = nextProps.record.data[nextProps.name];
this.loadCount(nextProps);
this.checkProps(nextProps); // not awaited
}
});
useBus(this.props.record.model.bus, "NEED_LOCAL_CHANGES", async (ev) => {
if (this.isDebugEdited) {
const prom = this.loadCount(this.props);
ev.detail.proms.push(prom);
await prom;
if (!this.state.isValid) {
this.props.record.setInvalidField(this.props.name);
}
const props = this.props;
ev.detail.proms.push(
this.checkProps(props).then(() => {
if (!this.state.isValid) {
props.record.setInvalidField(props.name);
}
})
);
}
});
}
getContext(p) {
return p.context;
getContext(props = this.props) {
return props.context;
}
getResModel(p) {
let resModel = p.resModel;
if (p.record.fieldNames.includes(resModel)) {
resModel = p.record.data[resModel];
getDomain(props = this.props) {
return props.record.data[props.name] || "[]";
}
getEvaluatedDomain(props = this.props) {
const domainStringRepr = this.getDomain(props);
const evalContext = this.getContext(props);
try {
const domain = new Domain(domainStringRepr).toList(evalContext);
// Here, there is still some incertitude on the domain validity.
// we could improve this check but a complete (async) check is done
// when loading the record count associated with the domain.
return domain;
} catch (error) {
if (error instanceof InvalidDomainError || error instanceof EvaluationError) {
return { isInvalid: true };
}
throw error;
}
}
getResModel(props = this.props) {
let resModel = props.resModel;
if (props.record.fieldNames.includes(resModel)) {
resModel = props.record.data[resModel];
}
return resModel;
}
async checkProps(props = this.props) {
const resModel = this.getResModel(props);
if (!resModel) {
this.updateState({});
return;
}
if (typeof resModel !== "string") {
// we don't want to support invalid models
throw new Error(`Invalid model: ${resModel}`);
}
const domain = this.getEvaluatedDomain(props);
if (domain.isInvalid) {
this.updateState({ isValid: false, recordCount: 0 });
return;
}
let recordCount;
const context = this.getContext(props);
try {
recordCount = await this.orm.silent.searchCount(resModel, domain, { context });
} catch (error) {
if (error.data?.name === "builtins.KeyError" && error.data.message === resModel) {
// we don't want to support invalid models
throw new Error(`Invalid model: ${resModel}`);
}
this.updateState({ isValid: false, recordCount: 0 });
return;
}
this.updateState({ isValid: true, recordCount });
}
onButtonClick() {
// resModel, domain, and context are assumed to be valid here.
this.addDialog(
SelectCreateDialog,
{
title: this.env._t("Selected records"),
noCreate: true,
multiSelect: false,
resModel: this.getResModel(this.props),
domain:
this.getDomain(this.props.record.data[this.props.name]).toList(
this.getContext(this.props)
) || [],
context: this.getContext(this.props) || {},
resModel: this.getResModel(),
domain: this.getEvaluatedDomain(),
context: this.getContext(),
},
{
// The counter is reloaded "on close" because some modal allows to modify data that can impact the counter
onClose: () => this.loadCount(this.props),
// The counter is reloaded "on close" because some modal allows
// to modify data that can impact the counter
onClose: () => this.checkProps(),
}
);
}
get isValidDomain() {
try {
this.getDomain(this.props.record.data[this.props.name]).toList();
return true;
} catch {
// WOWL TODO: rethrow error when not the expected type
return false;
}
}
getDomain(value) {
return new Domain(value || "[]");
}
async loadCount(props) {
if (!this.getResModel(props)) {
Object.assign(this.state, { recordCount: 0, isValid: true });
}
let recordCount;
try {
const domain = this.getDomain(props.record.data[props.name]).toList(
this.getContext(props)
);
recordCount = await this.orm.silent.call(
this.getResModel(props),
"search_count",
[domain],
{ context: this.getContext(props) }
);
} catch {
// WOWL TODO: rethrow error when not the expected type
Object.assign(this.state, { recordCount: 0, isValid: false });
return;
}
Object.assign(this.state, { recordCount, isValid: true });
}
update(domain, isDebugEdited) {
this.isDebugEdited = isDebugEdited;
this.props.record.update({ [this.props.name]: domain });
}
onEditDialogBtnClick() {
// resModel is assumed to be valid here
this.addDialog(DomainSelectorDialog, {
resModel: this.getResModel(this.props),
domain: this.props.record.data[this.props.name] || "[]",
readonly: this.props.readonly,
resModel: this.getResModel(),
domain: this.getDomain(),
isDebugMode: !!this.env.debug,
onConfirm: (value) => this.props.record.update({ [this.props.name]: value }),
onConfirm: this.update.bind(this),
});
}
update(domain, isDebugEdited = false) {
this.isDebugEdited = isDebugEdited;
return this.props.record.update({ [this.props.name]: domain });
}
updateState(params = {}) {
Object.assign(this.state, {
isValid: "isValid" in params ? params.isValid : null,
recordCount: "recordCount" in params ? params.recordCount : null,
});
}
}
@@ -3,20 +3,23 @@
<t t-name="web.DomainField" owl="1">
<div t-att-class="{ o_inline_mode: !props.editInDialog }">
<t t-if="getResModel(props)">
<t t-set="resModel" t-value="getResModel()"/>
<t t-if="resModel">
<DomainSelector
resModel="getResModel(props)"
value="displayedDomain || '[]'"
resModel="resModel"
domain="getDomain()"
readonly="props.readonly or props.editInDialog"
update.bind="update"
isDebugMode="!!env.debug"
debugValue="props.record.data[props.name] || '[]'"
className="props.readonly ? 'o_read_mode' : 'o_edit_mode'"
/>
<div class="o_field_domain_panel">
<t t-if="state.recordCount !== null">
<i class="fa fa-arrow-right" role="img" aria-label="Domain" title="Domain" />
<t t-if="state.isValid and isValidDomain">
<div class="o_field_domain_panel" style="height:22px;">
<i class="fa fa-arrow-right" role="img" aria-label="Domain" title="Domain" />
<t t-if="state.isValid === null and state.recordCount === null">
<i class="fa fa-circle-o-notch fa-spin ms-2" role="img" aria-label="Loading" title="Loading" />
</t>
<t t-else="">
<t t-if="state.isValid">
<button class="btn btn-sm btn-secondary o_domain_show_selection_button" type="button" t-on-click.stop="onButtonClick">
<t t-esc="state.recordCount" /> record(s)
</button>
@@ -32,16 +35,13 @@
role="img"
aria-label="Refresh"
title="Refresh"
t-on-click="() => this.loadCount(props)"
t-on-click="() => this.checkProps()"
/>
</t>
</t>
<t t-else="">
<i class="fa fa-circle-o-notch fa-spin" role="img" aria-label="Loading" title="Loading" />
</t>
<t t-if="props.editInDialog and !props.readonly">
<button class="btn btn-sm btn-primary o_field_domain_dialog_button" t-on-click.prevent="onEditDialogBtnClick">Edit Domain</button>
<t t-if="props.editInDialog and !props.readonly">
<button class="btn btn-sm btn-primary o_field_domain_dialog_button" t-on-click.prevent="onEditDialogBtnClick">Edit Domain</button>
</t>
</t>
</div>
</t>
@@ -133,11 +133,11 @@
<td>
<DomainSelector
resModel="state.propertyDefinition.comodel"
value="state.propertyDefinition.domain || '[]'"
domain="state.propertyDefinition.domain || '[]'"
readonly="props.readonly"
update.bind="onDomainChange"
isDebugMode="!!env.debug"
className="props.readonly ? 'o_read_mode d-inline-block me-2' : 'o_edit_mode d-inline-block me-2'"
className="'d-inline-block me-2'"
/>
<div t-if="state.matchingRecordsCount !== undefined" class="d-inline-block">
<i class="fa fa-arrow-right" role="img" title="Domain"/>
@@ -21,7 +21,6 @@ export const standardViewProps = {
bannerRoute: { type: String, optional: true },
className: { type: String, optional: true },
comparison: { type: [Object, { value: null }], optional: true },
// Issue OWL: https://github.com/odoo/owl/issues/910
context: { type: Object },
createRecord: { type: Function, optional: true },
display: { type: Object, optional: true },
@@ -30,6 +30,7 @@ import {
import { createWebClient, doAction, getActionManagerServerData } from "../../webclient/helpers";
import { openViewItem } from "@web/webclient/debug_items";
import { editSearchView, editView, setDefaults, viewMetadata } from "@web/views/debug_items";
import { fieldService } from "@web/core/field_service";
import { Component, xml } from "@odoo/owl";
const { prepareRegistriesWithCleanup } = utils;
@@ -56,6 +57,7 @@ QUnit.module("DebugMenu", (hooks) => {
.add("orm", ormService)
.add("dialog", makeFakeDialogService())
.add("localization", makeFakeLocalizationService())
.add("field", fieldService)
.add("command", fakeCommandService);
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
File diff suppressed because it is too large Load Diff
@@ -32,8 +32,9 @@ async function mountComponent(Component, params = {}) {
return mount(Component, target, { env, props: params.props || {} });
}
export async function openModelFieldSelectorPopover(target) {
await click(target, ".o_model_field_selector");
export async function openModelFieldSelectorPopover(target, index = 0) {
const el = target.querySelectorAll(".o_model_field_selector")[index];
await click(el);
}
function getDisplayedFieldNames(target) {
@@ -154,7 +155,7 @@ QUnit.module("Components", (hooks) => {
resModel="'partner'"
path="path"
isDebugMode="false"
update="(pathInfo) => this.onUpdate(pathInfo)"
update="(path) => this.onUpdate(path)"
/>
`;
@@ -392,7 +393,7 @@ QUnit.module("Components", (hooks) => {
readonly="false"
resModel="'partner'"
path="path"
update="(pathInfo) => this.onUpdate(pathInfo)"
update="(path) => this.onUpdate(path)"
/>
`;
@@ -1,657 +0,0 @@
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import {
click,
editInput,
getFixture,
getNodesTextContent,
patchDate,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { browser } from "@web/core/browser/browser";
import { fieldService } from "@web/core/field_service";
import { registry } from "@web/core/registry";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import {
editFavoriteName,
getFacetTexts,
makeWithSearch,
openAdvancedSearchDialog,
saveFavorite,
setupControlPanelFavoriteMenuRegistry,
setupControlPanelServiceRegistry,
toggleComparisonMenu,
toggleFavoriteMenu,
toggleFilterMenu,
toggleGroupByMenu,
toggleMenuItem,
toggleMenuItemOption,
toggleSaveFavorite,
} from "./helpers";
async function makeTestComponent(props) {
class TestComponent extends Component {
setup() {
this.id = 1; // used for t-key. We want to be sure that the control panel is rendered.
}
}
TestComponent.components = { ControlPanel };
TestComponent.template = xml`
<div class="o_test_component">
<ControlPanel t-key="id++"/>
</div>
`;
return makeWithSearch({
serverData,
resModel: "foo",
Component: TestComponent,
searchViewId: 1,
searchMenuTypes: ["filter", "groupBy", "comparison", "favorite"],
...props,
});
}
let target;
let serverData;
QUnit.module("Search", (hooks) => {
hooks.beforeEach(async () => {
serverData = {
models: {
foo: {
fields: {
date_field: {
name: "date_field",
string: "A date",
type: "date",
searchable: true,
},
date_time_field: {
name: "date_time_field",
string: "DateTime",
type: "datetime",
searchable: true,
},
boolean_field: {
name: "boolean_field",
string: "Boolean Field",
type: "boolean",
default: true,
searchable: true,
},
binary_field: {
name: "binary_field",
string: "Binary Field",
type: "binary",
searchable: true,
},
char_field: {
name: "char_field",
string: "Char Field",
type: "char",
default: "foo",
trim: true,
searchable: true,
},
float_field: {
name: "float_field",
string: "Floaty McFloatface",
type: "float",
searchable: true,
},
color: {
name: "color",
string: "Color",
type: "selection",
selection: [
["black", "Black"],
["white", "White"],
],
searchable: true,
},
user_id: {
string: "User",
type: "many2one",
relation: "user",
store: true,
sortable: true,
},
},
},
user: {
fields: {
id: { string: "Id", type: "integer" },
name: { string: "Product Name", type: "char" },
},
records: [
{
id: 37,
display_name: "Me",
},
],
},
},
views: {
"foo,1,search": `
<search>
<field name="char_field"/>
<filter name="simple_filter" string="Simple Filter" domain="[('float_field', '=', 2)]"/>
<filter name="dynamic_filter" string="Dynamic Filter" domain="[('user_id', '=', uid)]"/>
<filter name="date_filter" string="Date Filter" date="date_field"/>
<filter name="simple_group_by" string="Simple GroupBy" context="{'group_by': 'boolean_field'}"/>
<filter name="date_group_by" string="Date GroupBy" context="{'group_by': 'date_time_field'}"/>
</search>
`,
},
};
setupControlPanelServiceRegistry();
setupControlPanelFavoriteMenuRegistry();
registry.category("services").add("field", fieldService);
target = getFixture();
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
clearTimeout: () => {},
});
});
QUnit.module("AdvancedSearchDialog");
QUnit.test("basic rendering", async function (assert) {
await makeTestComponent();
await toggleFilterMenu(target);
assert.containsNone(target, ".modal");
assert.containsOnce(target, ".o_filter_menu .dropdown-menu");
await openAdvancedSearchDialog(target);
assert.containsNone(target, ".o_filter_menu .dropdown-menu");
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".modal .modal-header h4");
assert.strictEqual(target.querySelector(".modal header h4").innerText, "Advanced Search");
assert.containsN(target, ".modal footer button", 2);
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".modal footer button")), [
"Search",
"Discard",
]);
await click(target, ".modal footer button:nth-child(2)");
assert.containsNone(target, ".modal");
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
assert.containsOnce(target, ".modal");
await click(target, ".modal footer button:nth-child(1)");
assert.containsNone(target, ".modal");
});
QUnit.test("start with an empty query", async function (assert) {
await makeTestComponent();
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".modal .modal-body .o_domain_selector");
assert.containsOnce(target, ".modal .modal-body .o_domain_selector span");
assert.strictEqual(
target.querySelector(".modal .modal-body .o_domain_selector span").innerText,
"Match all records"
);
assert.containsOnce(
target,
".modal .modal-body .o_domain_selector button.o_domain_add_first_node_button"
);
});
QUnit.test("start with an empty query and a global domain", async function (assert) {
await makeTestComponent({ domain: [[0, "=", 1]] });
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
// the global domain should not be part of the domain passed to domain selector
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".modal .modal-body .o_domain_selector");
assert.containsOnce(target, ".modal .modal-body .o_domain_selector span");
assert.strictEqual(
target.querySelector(".modal .modal-body .o_domain_selector span").innerText,
"Match all records"
);
assert.containsOnce(
target,
".modal .modal-body .o_domain_selector button.o_domain_add_first_node_button"
);
});
QUnit.test("start with a simple domain and modify it", async function (assert) {
const testComponent = await makeTestComponent({
context: { search_default_simple_filter: true },
});
await toggleFilterMenu(target);
assert.deepEqual(getFacetTexts(target), ["Simple Filter"]);
assert.containsNone(target, ".o_domain_selector");
await openAdvancedSearchDialog(target);
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".modal .modal-body .o_domain_selector");
assert.containsOnce(target, ".o_model_field_selector");
assert.strictEqual(
target.querySelector(".o_model_field_selector").innerText,
"Floaty McFloatface"
);
assert.containsOnce(target, ".o_domain_leaf_operator_select");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "equal");
assert.containsOnce(target, ".o_domain_leaf_value_input");
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "2");
await editInput(target, ".o_domain_leaf_value_input", 4);
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "4");
assert.deepEqual(testComponent.props.domain, [["float_field", "=", 2]]);
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Advanced Search"]);
assert.deepEqual(testComponent.props.domain, [["float_field", "=", 4]]);
});
QUnit.test("start with a dynamic domain (evaluation is done)", async function (assert) {
const testComponent = await makeTestComponent({
context: {
search_default_dynamic_filter: true,
},
mockRPC(_, { args, method }) {
if (method === "create_or_replace") {
assert.strictEqual(args[0].domain, `[("user_id", "=", 4)]`); // the domain is not saved dynamically
return 9; // fake serverId to simulate the creation of
// the favorite in db.
}
},
});
assert.deepEqual(getFacetTexts(target), ["Dynamic Filter"]);
assert.deepEqual(testComponent.props.domain, [["user_id", "=", 7]]);
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
assert.strictEqual(target.querySelector(".o_model_field_selector").innerText, "User");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "equal");
assert.strictEqual(target.querySelector(".o_ds_value_cell input").value, "7");
});
QUnit.test(
"start with a simple domain and a simple group by, group by not lost after search",
async function (assert) {
const testComponent = await makeTestComponent({
context: {
search_default_simple_filter: true,
search_default_simple_group_by: true,
},
});
assert.deepEqual(getFacetTexts(target), ["Simple Filter", "Simple GroupBy"]);
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "2");
assert.deepEqual(testComponent.props.domain, [["float_field", "=", 2]]);
assert.deepEqual(testComponent.props.groupBy, ["boolean_field"]);
await editInput(target, ".o_domain_leaf_value_input", 4);
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Simple GroupBy", "Advanced Search"]);
assert.deepEqual(testComponent.props.domain, [["float_field", "=", 4]]);
assert.deepEqual(testComponent.props.groupBy, ["boolean_field"]);
}
);
QUnit.test("start with a favorite only", async function (assert) {
const irFilters = [
{
context: "{ 'some_key': 'some_value', 'group_by': ['color'] }",
domain: "[('char_field', 'ilike', 'abc')]",
id: 1,
is_default: true,
name: "My favorite",
sort: "[]",
user_id: [2, "Mitchell Admin"],
},
];
const testComponent = await makeTestComponent({
irFilters,
});
assert.deepEqual(getFacetTexts(target), ["My favorite"]);
assert.deepEqual(testComponent.props.domain, [["char_field", "ilike", "abc"]]);
assert.deepEqual(testComponent.props.groupBy, ["color"]);
assert.strictEqual(testComponent.props.context.some_key, "some_value");
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
await editInput(target.querySelector(".o_domain_leaf_value_input"), null, "xyz");
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "xyz");
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Color", "Advanced Search"]);
assert.deepEqual(testComponent.props.domain, [["char_field", "ilike", "xyz"]]);
assert.deepEqual(testComponent.props.groupBy, ["color"]);
assert.strictEqual(testComponent.props.context.some_key, "some_value");
});
QUnit.test("group_bys retrieved from favorite", async function (assert) {
const irFilters = [
{
context: "{ 'some_key': 'some_value', 'group_by': ['color'] }",
domain: "[('char_field', 'ilike', 'abc')]",
id: 1,
is_default: true,
name: "My favorite",
sort: "[]",
user_id: [2, "Mitchell Admin"],
},
];
const testComponent = await makeTestComponent({
irFilters,
});
await toggleGroupByMenu(target);
await toggleMenuItem(target, "Simple GroupBy");
assert.deepEqual(getFacetTexts(target), ["My favorite", "Simple GroupBy"]);
await toggleFilterMenu(target);
await toggleMenuItem(target, "Simple Filter");
assert.deepEqual(getFacetTexts(target), ["My favorite", "Simple GroupBy", "Simple Filter"]);
assert.deepEqual(testComponent.props.domain, [
"&",
["char_field", "ilike", "abc"],
["float_field", "=", 2],
]);
assert.deepEqual(testComponent.props.groupBy, ["color", "boolean_field"]);
assert.strictEqual(testComponent.props.context.some_key, "some_value");
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
await editInput(target.querySelector(".o_domain_leaf_value_input"), null, "xyz");
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "xyz");
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Color>Simple GroupBy", "Advanced Search"]);
assert.deepEqual(testComponent.props.domain, [
"&",
["char_field", "ilike", "xyz"],
["float_field", "=", 2],
]);
assert.deepEqual(testComponent.props.groupBy, ["color", "boolean_field"]);
assert.strictEqual(testComponent.props.context.some_key, "some_value");
});
QUnit.test(
"comparison retrieved from favorite (comparison in searchMenuTypes)",
async function (assert) {
patchDate(2023, 2, 1, 14, 0, 0);
const testComponent = await makeTestComponent({
async mockRPC(_, { method }) {
if (method === "create_or_replace") {
return 9;
}
},
});
await toggleFilterMenu(target);
await toggleMenuItem(target, "Date Filter");
await toggleMenuItemOption(target, "Date Filter", "March");
await toggleComparisonMenu(target);
await toggleMenuItem(target, "Date Filter: Previous Period");
await toggleFavoriteMenu(target);
await toggleSaveFavorite(target);
await editFavoriteName(target, "My new favorite");
await saveFavorite(target, "My new favorite");
assert.deepEqual(getFacetTexts(target), ["My new favorite"]);
assert.deepEqual(testComponent.props.domain, []);
assert.deepEqual(testComponent.props.comparison, {
domains: [
{
arrayRepr: [
"&",
["date_field", ">=", "2023-03-01"],
["date_field", "<=", "2023-03-31"],
],
description: "March 2023",
},
{
arrayRepr: [
"&",
["date_field", ">=", "2023-02-01"],
["date_field", "<=", "2023-02-28"],
],
description: "February 2023",
},
],
fieldName: "date_field",
});
assert.deepEqual(testComponent.props.groupBy, []);
assert.strictEqual(testComponent.props.context.comparison, undefined);
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Advanced Search"]);
assert.deepEqual(testComponent.props.domain, [
"&",
["date_field", ">=", "2023-03-01"],
["date_field", "<=", "2023-03-31"],
]);
assert.deepEqual(testComponent.props.groupBy, []);
assert.strictEqual(testComponent.props.context.comparison, undefined);
}
);
QUnit.test(
"comparison retrieved from favorite (comparison not in searchMenuTypes)",
async function (assert) {
const testComponent = await makeTestComponent({
irFilters: [
{
context: `{
'group_by': [],
'comparison': {
'comparisonId': 'previous_period',
'fieldName': 'create_date',
'fieldDescription':'Creation Date',
'range': ['&', ['create_date', '>=', '2023-02-28 23:00:00'], ['create_date', '<=', '2023-03-31 21:59:59']],
'rangeDescription': 'March 2023',
'comparisonRange': ['&', ['create_date', '>=', '2023-01-31 23:00:00'], ['create_date', '<=', '2023-02-28 22:59:59']],
'comparisonRangeDescription': 'February 2023'}
}`,
domain: "[]",
id: 1,
is_default: true,
name: "My favorite",
sort: "[]",
user_id: [2, "Mitchell Admin"],
},
],
searchMenuTypes: ["filter", "favorite"],
});
assert.deepEqual(getFacetTexts(target), ["My favorite"]);
assert.deepEqual(testComponent.props.domain, []); // a problem with comparison?
assert.strictEqual(testComponent.props.comparison, null);
assert.deepEqual(testComponent.props.groupBy, []);
assert.strictEqual(testComponent.props.context.comparison, undefined);
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Advanced Search"]);
assert.deepEqual(testComponent.props.domain, []);
assert.deepEqual(testComponent.props.groupBy, []);
assert.strictEqual(testComponent.props.context.comparison, undefined);
}
);
QUnit.test("search items created for advanced search are invisible", async function (assert) {
const irFilters = [
{
context: "{ 'group_by': ['color'] }",
domain: "[]]",
id: 1,
is_default: true,
name: "My favorite",
sort: "[]",
user_id: [2, "Mitchell Admin"],
},
];
const testComponent = await makeTestComponent({
irFilters,
});
assert.deepEqual(getFacetTexts(target), ["My favorite"]);
assert.deepEqual(testComponent.props.domain, []);
assert.deepEqual(testComponent.props.groupBy, ["color"]);
await toggleGroupByMenu(target);
const visibleGroupBys = getNodesTextContent(
target.querySelectorAll(".o_group_by_menu .dropdown-item")
);
await toggleFilterMenu(target);
const visibleFilters = getNodesTextContent(
target.querySelectorAll(".o_filter_menu .dropdown-item")
);
await openAdvancedSearchDialog(target);
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(getFacetTexts(target), ["Color", "Advanced Search"]);
assert.deepEqual(testComponent.props.domain, []);
assert.deepEqual(testComponent.props.groupBy, ["color"]);
await toggleGroupByMenu(target);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_group_by_menu .dropdown-item")),
visibleGroupBys
);
await toggleFilterMenu(target);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_filter_menu .dropdown-item")),
visibleFilters
);
});
QUnit.test("Several date filters and a comparison", async function (assert) {
patchDate(2023, 3, 1, 0, 0, 0);
serverData.views["foo,1,search"] = `
<search>
<filter name="date_filter" string="Date" date="date_field"/>
<filter name="date_time_filter" string="Datetime" date="date_time_field"/>
</search>
`;
const testComponent = await makeTestComponent({
domain: [[0, "=", 1]],
context: {
search_default_date_filter: true,
search_default_date_time_filter: true,
},
});
await toggleComparisonMenu(target);
await toggleMenuItem(target, "Date: Previous Period");
assert.deepEqual(getFacetTexts(target), [
"Date: April 2023orDatetime: April 2023",
"Date: Previous Period",
]);
assert.deepEqual(testComponent.props.domain, [
"&",
[0, "=", 1],
"&",
["date_time_field", ">=", "2023-03-31 23:00:00"],
["date_time_field", "<=", "2023-04-30 22:59:59"],
]);
assert.deepEqual(testComponent.props.comparison, {
domains: [
{
arrayRepr: [
"&",
"&",
[0, "=", 1],
"&",
["date_time_field", ">=", "2023-03-31 23:00:00"],
["date_time_field", "<=", "2023-04-30 22:59:59"],
"&",
["date_field", ">=", "2023-04-01"],
["date_field", "<=", "2023-04-30"],
],
description: "April 2023",
},
{
arrayRepr: [
"&",
"&",
[0, "=", 1],
"&",
["date_time_field", ">=", "2023-03-31 23:00:00"],
["date_time_field", "<=", "2023-04-30 22:59:59"],
"&",
["date_field", ">=", "2023-03-01"],
["date_field", "<=", "2023-03-31"],
],
description: "March 2023",
},
],
fieldName: "date_field",
});
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_model_field_selector_chain_part")),
["DateTime", "DateTime", "A date", "A date"]
); // 0 not found!
await click(target, ".modal footer button:nth-child(1)");
assert.deepEqual(testComponent.props.domain, [
"&",
[0, "=", 1],
"&",
"&",
["date_time_field", ">=", "2023-03-31 23:00:00"],
["date_time_field", "<=", "2023-04-30 22:59:59"],
"&",
["date_field", ">=", "2023-04-01"],
["date_field", "<=", "2023-04-30"],
]);
});
QUnit.test("an invalid domain cannot be saved", async function (assert) {
assert.expect(9);
patchWithCleanup(odoo, {
debug: true,
});
registry.category("services").add(
"notification",
{
start() {
return {
add(message, options) {
assert.strictEqual(message, "Domain is invalid. Please correct it");
assert.deepEqual(options, { type: "danger" });
},
};
},
},
{ force: true }
);
const testComponent = await makeTestComponent({});
assert.deepEqual(testComponent.props.domain, []);
assert.deepEqual(getFacetTexts(target), []);
assert.containsNone(target, ".modal");
await toggleFilterMenu(target);
await openAdvancedSearchDialog(target);
assert.containsOnce(target, ".modal");
await editInput(target, "textarea.o_domain_debug_input", "[");
await click(target, ".modal footer button:nth-child(1)");
assert.containsOnce(target, ".modal");
assert.deepEqual(testComponent.props.domain, []);
assert.deepEqual(getFacetTexts(target), []);
});
});
@@ -1,6 +1,8 @@
/** @odoo-module **/
import {
click,
editInput,
getFixture,
getNodesTextContent,
patchDate,
@@ -13,14 +15,12 @@ import {
isItemSelected,
isOptionSelected,
makeWithSearch,
openAdvancedSearchDialog,
openAddCustomFilterDialog,
setupControlPanelServiceRegistry,
toggleFilterMenu,
toggleMenuItem,
toggleMenuItemOption,
} from "./helpers";
import { registry } from "@web/core/registry";
import { AdvancedSearchDialog } from "@web/search/filter_menu/advanced_search_dialog";
function getDomain(controlPanel) {
return controlPanel.env.searchModel.domain;
@@ -47,7 +47,7 @@ QUnit.module("Search", (hooks) => {
},
foo: { string: "Foo", type: "char", store: true, sortable: true },
},
records: {},
records: [],
},
},
views: {
@@ -76,7 +76,7 @@ QUnit.module("Search", (hooks) => {
assert.containsNone(target, ".o_menu_item");
assert.containsNone(target, ".dropdown-divider");
assert.containsOnce(target, ".dropdown-item");
assert.strictEqual(target.querySelector(".dropdown-item").innerText, "Advanced Search");
assert.strictEqual(target.querySelector(".dropdown-item").innerText, "Add Custom Filter");
});
QUnit.test("simple rendering with a single filter", async function (assert) {
@@ -101,7 +101,7 @@ QUnit.module("Search", (hooks) => {
assert.containsOnce(target, ".dropdown-item:not(.o_menu_item)");
assert.strictEqual(
target.querySelector(".dropdown-item:not(.o_menu_item)").innerText,
"Advanced Search"
"Add Custom Filter"
);
});
@@ -596,27 +596,77 @@ QUnit.module("Search", (hooks) => {
});
});
QUnit.test("Open 'Advanced Search' dialog", async function (assert) {
assert.expect(3);
registry.category("services").add(
"dialog",
{
start() {
return {
add: (dialogClass, props) => {
assert.strictEqual(dialogClass, AdvancedSearchDialog);
assert.deepEqual(Object.keys(props), [
"domain",
"onConfirm",
"resModel",
"isDebugMode",
]);
},
};
},
},
{ force: true }
QUnit.test("Open 'Add Custom Filter' dialog", async function (assert) {
await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(target);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_filter_menu .dropdown-item")),
["Add Custom Filter"]
);
assert.containsNone(target, ".modal");
await openAddCustomFilterDialog(target);
assert.containsOnce(target, ".modal");
assert.strictEqual(target.querySelector(".modal header").innerText, "Add Custom Filter");
assert.containsOnce(target, ".modal .o_domain_selector");
assert.containsOnce(target, ".modal .o_domain_selector .o_domain_leaf");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".modal footer button")), [
"Add",
"Cancel",
]);
});
QUnit.test(
"Default leaf in 'Add Custom Filter' dialog is based on ID (if no special fields on model)",
async function (assert) {
await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(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"
);
}
);
QUnit.test(
"Default leaf in 'Add Custom Filter' dialog is based on first special field (if any special fields on model)",
async function (assert) {
serverData.models.foo.fields.country_id = {
string: "Country",
type: "many2one",
};
await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(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"
);
}
);
QUnit.test("Default connector is '|' (any)", async function (assert) {
await makeWithSearch({
serverData,
resModel: "foo",
@@ -624,10 +674,127 @@ QUnit.module("Search", (hooks) => {
searchMenuTypes: ["filter"],
});
await toggleFilterMenu(target);
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_filter_menu .dropdown-item")),
["Advanced Search"]
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"
);
await openAdvancedSearchDialog(target);
assert.containsNone(target, "button.o_domain_tree_connector_caret");
await click(target, ".o_domain_add_node_button .fa-plus");
assert.containsOnce(target, "button.o_domain_tree_connector_caret");
assert.strictEqual(
target.querySelector("button.o_domain_tree_connector_caret").innerText,
"any"
);
assert.containsN(target, ".modal .o_domain_selector .o_domain_leaf", 2);
});
QUnit.test("Add a custom filter", async function (assert) {
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchMenuTypes: ["filter"],
searchViewId: false,
searchViewArch: `
<search>
<filter string="Filter" name="filter" domain="[('foo', '=', 'abc')]"/>
</search>
`,
context: {
search_default_filter: true,
},
});
assert.deepEqual(getFacetTexts(target), ["Filter"]);
assert.deepEqual(getDomain(controlPanel), [["foo", "=", "abc"]]);
await toggleFilterMenu(target);
await openAddCustomFilterDialog(target);
await click(target, ".o_domain_add_node_button .fa-plus");
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.querySelectorAll(".o_domain_add_node_button .fa-sitemap")].at(-1));
await click([...target.querySelectorAll(".o_domain_add_node_button .fa-sitemap")].at(-1));
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), [
"Filter",
"ID = 1",
"ID = 1",
"ID = 1 or ID = 1 or ( ID = 1 and ID = 1 )",
]);
assert.deepEqual(getDomain(controlPanel), [
"&",
["foo", "=", "abc"],
"&",
["id", "=", 1],
"&",
["id", "=", 1],
"|",
"|",
["id", "=", 1],
["id", "=", 1],
"&",
["id", "=", 1],
["id", "=", 1],
]);
});
QUnit.test("Add a custom filter containing an expression", async function (assert) {
patchWithCleanup(odoo, { debug: true });
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchMenuTypes: ["filter"],
searchViewId: false,
searchViewArch: `<search />`,
});
assert.deepEqual(getFacetTexts(target), []);
assert.deepEqual(getDomain(controlPanel), []);
await toggleFilterMenu(target);
await openAddCustomFilterDialog(target);
await editInput(target, ".o_domain_debug_input", `[("foo", "in", [uid, 1, "a"])]`);
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), [`Foo in uid or 1 or "a"`]);
assert.deepEqual(getDomain(controlPanel), [
["foo", "in", [7, 1, "a"]], // uid = 7
]);
});
QUnit.test("Add a custom filter containing a between operator", async function (assert) {
patchWithCleanup(odoo, { debug: true });
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
searchMenuTypes: ["filter"],
searchViewId: false,
searchViewArch: `<search />`,
});
assert.deepEqual(getFacetTexts(target), []);
assert.deepEqual(getDomain(controlPanel), []);
await toggleFilterMenu(target);
await openAddCustomFilterDialog(target);
await editInput(target, ".o_domain_debug_input", `[("id", "between", [0, 10])]`);
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), [`ID is between 0 and 10`]);
assert.deepEqual(getDomain(controlPanel), ["&", ["id", ">=", 0], ["id", "<=", 10]]);
});
});
+3 -1
View File
@@ -13,6 +13,7 @@ import {
} from "@web/../tests/helpers/utils";
import { commandService } from "@web/core/commands/command_service";
import { dialogService } from "@web/core/dialog/dialog_service";
import { fieldService } from "@web/core/field_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { notificationService } from "@web/core/notifications/notification_service";
import { ormService } from "@web/core/orm_service";
@@ -31,6 +32,7 @@ const favoriteMenuRegistry = registry.category("favoriteMenu");
export function setupControlPanelServiceRegistry() {
serviceRegistry.add("action", actionService);
serviceRegistry.add("dialog", dialogService);
serviceRegistry.add("field", fieldService);
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("notification", notificationService);
serviceRegistry.add("orm", ormService);
@@ -203,7 +205,7 @@ export async function toggleFilterMenu(el) {
await click(findItem(el, `.o_filter_menu button.dropdown-toggle`));
}
export async function openAdvancedSearchDialog(el) {
export async function openAddCustomFilterDialog(el) {
await click(findItem(el, `.o_filter_menu .dropdown-item:not(.o_menu_item)`));
}
@@ -4,8 +4,10 @@ import { ControlPanel } from "@web/search/control_panel/control_panel";
import {
click,
getFixture,
getNodesTextContent,
makeDeferred,
nextTick,
patchDate,
patchTimeZone,
patchWithCleanup,
triggerEvent,
@@ -16,9 +18,16 @@ import {
makeWithSearch,
removeFacet,
setupControlPanelServiceRegistry,
toggleComparisonMenu,
toggleGroupByMenu,
toggleMenuItem,
validateSearch,
} from "./helpers";
function getContext(controlPanel) {
return controlPanel.env.searchModel.context;
}
function getDomain(controlPanel) {
return controlPanel.env.searchModel.domain;
}
@@ -1413,4 +1422,271 @@ QUnit.module("Search", (hooks) => {
assert.strictEqual(items.length, 2, "Should show the search result");
assert.strictEqual(items[1].innerText, "My Text (Bar 2) for: a");
});
QUnit.test("edit a filter", async function (assert) {
await makeWithSearch({
serverData,
resModel: "partner",
Component: ControlPanel,
searchMenuTypes: ["groupBy"], // we need it to have facet (see facets getter in search_model)
searchViewId: false,
searchViewArch: `
<search>
<filter name="filter" string="Filter" domain="[('birthday', '>=', context_today())]"/>
<filter name="bool" string="Bool" domain="[]" context="{'group_by': 'bool'}"/>
</search>
`,
context: {
search_default_filter: true,
search_default_bool: true,
},
});
assert.deepEqual(getFacetTexts(target), ["Filter", "Bool"]);
assert.containsN(target, ".o_searchview_facet .o_searchview_facet_label", 2);
assert.containsOnce(
target,
".o_searchview_facet.o_facet_with_domain .o_searchview_facet_label"
);
assert.containsNone(target, ".modal");
await click(target, ".o_facet_with_domain .o_searchview_facet_label");
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.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,
"greater_equal"
);
assert.strictEqual(target.querySelector(".o_ds_expr_value").innerText, "context_today()");
assert.notOk(target.querySelector(".modal footer button").disabled);
await click(target, ".o_domain_delete_node_button");
assert.containsNone(target, ".o_domain_leaf");
assert.ok(target.querySelector(".modal footer button").disabled);
await click(target, ".modal .o_domain_add_first_node_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, "equal");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "1");
await click(target.querySelector(".modal footer button"));
assert.containsNone(target, ".modal");
assert.deepEqual(getFacetTexts(target), ["Bool", "ID = 1"]);
});
QUnit.test(
"edit a filter with context: context is kept after edition",
async function (assert) {
const controlPanel = await makeWithSearch({
serverData,
resModel: "partner",
Component: ControlPanel,
searchViewId: false,
searchViewArch: `
<search>
<filter name="filter" string="Filter" context="{'specialKey': 'abc'}" domain="[('foo', '=', 'abc')]"/>
</search>
`,
context: {
search_default_filter: true,
},
});
assert.deepEqual(getFacetTexts(target), ["Filter"]);
assert.deepEqual(getContext(controlPanel).specialKey, "abc");
await click(target, ".o_facet_with_domain .o_searchview_facet_label");
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), [`Foo = "abc"`]);
assert.deepEqual(getContext(controlPanel).specialKey, "abc");
}
);
QUnit.test("edit a favorite", async function (assert) {
const irFilters = [
{
context: "{ 'some_key': 'some_value', 'group_by': ['bool'] }",
domain: "[('foo', 'ilike', 'abc')]",
id: 1,
is_default: true,
name: "My favorite",
sort: "[]",
user_id: [2, "Mitchell Admin"],
},
];
await makeWithSearch({
serverData,
resModel: "partner",
Component: ControlPanel,
searchMenuTypes: ["groupBy"], // we need it to have facet (see facets getter in search_model)
searchViewId: false,
searchViewArch: `
<search>
<filter name="company" string="Company" domain="[]" context="{'group_by': 'company'}"/>
</search>
`,
irFilters,
});
assert.deepEqual(getFacetTexts(target), ["My favorite"]);
assert.containsOnce(
target,
".o_searchview_facet.o_facet_with_domain .o_searchview_facet_label"
);
await toggleGroupByMenu(target);
await toggleMenuItem(target, "Company");
assert.deepEqual(getFacetTexts(target), ["My favorite", "Company"]);
assert.containsN(target, ".o_searchview_facet .o_searchview_facet_label", 2);
assert.containsOnce(
target,
".o_searchview_facet.o_facet_with_domain .o_searchview_facet_label"
);
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");
await click(target.querySelector(".modal footer button"));
assert.containsNone(target, ".modal");
assert.deepEqual(getFacetTexts(target), ["Bool>Company", 'Foo contains "abc"']);
});
QUnit.test("edit a date filter with comparison active", async function (assert) {
patchDate(2023, 3, 28, 13, 40, 0);
await makeWithSearch({
serverData,
resModel: "partner",
Component: ControlPanel,
searchMenuTypes: ["filter", "comparison"],
searchViewId: false,
searchViewArch: `
<search>
<filter name="birthday" string="Birthday" date="birthday"/>
</search>
`,
context: {
search_default_birthday: true,
},
});
assert.deepEqual(getFacetTexts(target), ["Birthday: April 2023"]);
assert.containsOnce(
target,
".o_searchview_facet.o_facet_with_domain .o_searchview_facet_label"
);
await toggleComparisonMenu(target);
await toggleMenuItem(target);
assert.deepEqual(getFacetTexts(target), [
"Birthday: April 2023",
"Birthday: Previous Period",
]);
assert.containsOnce(
target,
".o_searchview_facet.o_facet_with_domain .o_searchview_facet_label"
);
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"
);
await click(target.querySelector(".modal footer button"));
assert.containsNone(target, ".modal");
assert.deepEqual(getFacetTexts(target), [
`Birthday is between "2023-04-01" and "2023-04-30"`,
]);
});
QUnit.test("edit a field", async function (assert) {
await makeWithSearch({
serverData,
resModel: "partner",
Component: ControlPanel,
searchViewId: false,
searchViewArch: `
<search>
<field name="foo"/>
</search>
`,
context: {
search_default_foo: "abc",
},
});
assert.deepEqual(getFacetTexts(target), ["Foo\nabc"]);
assert.containsOnce(
target,
".o_searchview_facet.o_facet_with_domain .o_searchview_facet_label"
);
await editSearch(target, "def");
const searchInput = target.querySelector(".o_searchview input");
await triggerEvent(searchInput, null, "keydown", { key: "ArrowDown" });
await triggerEvent(searchInput, null, "keydown", { key: "Enter" }); // select
assert.deepEqual(getFacetTexts(target), ["Foo\nabcordef"]);
await click(target, ".o_facet_with_domain .o_searchview_facet_label");
assert.containsN(target, ".o_domain_leaf", 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(
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"
);
await click(target.querySelector(".modal footer button"));
assert.deepEqual(getFacetTexts(target), [`Foo contains "abc" or Foo contains "def"`]);
});
});
@@ -9,12 +9,11 @@ import {
getFixture,
makeDeferred,
nextTick,
patchDate,
patchWithCleanup,
triggerEvent,
} from "@web/../tests/helpers/utils";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { fieldService } from "@web/core/field_service";
import { registry } from "@web/core/registry";
import { getPickerCell } from "../../core/datetime/datetime_test_helpers";
let serverData;
@@ -92,9 +91,7 @@ QUnit.module("Fields", (hooks) => {
},
},
};
setupViewRegistries();
registry.category("services").add("field", fieldService);
});
QUnit.module("DomainField");
@@ -103,7 +100,7 @@ QUnit.module("Fields", (hooks) => {
"The domain editor should not crash the view when given a dynamic filter",
async function (assert) {
// dynamic filters (containing variables, such as uid, parent or today)
// are not handled by the domain editor, but it shouldn't crash the view
// are handled by the domain editor
serverData.models.partner.records[0].foo = `[("int_field", "=", uid)]`;
await makeView({
@@ -119,9 +116,9 @@ QUnit.module("Fields", (hooks) => {
});
assert.strictEqual(
target.querySelector(".o_edit_mode").textContent,
" This domain is not supported. Reset domain",
"The widget should not crash the view, but gracefully admit its failure."
target.querySelector(".o_ds_value_cell").textContent,
"uid",
"The widget should show the dynamic filter."
);
}
);
@@ -129,10 +126,8 @@ QUnit.module("Fields", (hooks) => {
QUnit.test(
"The domain editor should not crash the view when given a dynamic filter ( datetime )",
async function (assert) {
// dynamic filters (containing variables, such as uid, parent or today)
// are not handled by the domain editor, but it shouldn't crash the view
serverData.models.partner.records[0].foo = `[("datetime", "=", context_today())]`;
serverData.models.partner.fields.datetime = { string: "A date", type: "datetime" };
serverData.models.partner.records[0].foo = `[("datetime", "=", context_today())]`;
await makeView({
type: "form",
@@ -145,8 +140,12 @@ QUnit.module("Fields", (hooks) => {
</form>`,
});
// The input field should display that the date is invalid
assert.equal(target.querySelector(".o_datetime_input").value, "Invalid DateTime");
assert.equal(
target.querySelector(".o_ds_value_cell .o_ds_expr_value").innerText,
"context_today()"
);
await click(target, ".o_ds_value_cell .fa.fa-times");
// Change the date in the datepicker
await click(target, ".o_datetime_input");
@@ -157,7 +156,10 @@ QUnit.module("Fields", (hooks) => {
await clickDiscard(target);
// Open the datepicker again
await click(target, ".o_datetime_input");
assert.equal(
target.querySelector(".o_ds_value_cell .o_ds_expr_value").innerText,
"context_today()"
);
}
);
@@ -181,11 +183,7 @@ QUnit.module("Fields", (hooks) => {
// As the domain is empty, there should be a button to add the first
// domain part
assert.containsOnce(
target,
".o_domain_add_first_node_button",
"there should be a button to create first domain element"
);
assert.containsOnce(target, ".o_domain_add_first_node_button");
// Clicking on the button should add the [["id", "=", "1"]] domain, so
// there should be a field selector in the DOM
@@ -195,23 +193,14 @@ QUnit.module("Fields", (hooks) => {
// Focusing the field selector input should open the field selector
// popover
await click(target, ".o_model_field_selector");
assert.containsOnce(
document.body,
".o_model_field_selector_popover",
"field selector popover should be visible"
);
assert.containsOnce(
document.body,
".o_model_field_selector_popover_search input",
"field selector popover should contain a search input"
);
assert.containsOnce(document.body, ".o_model_field_selector_popover");
assert.containsOnce(document.body, ".o_model_field_selector_popover_search input");
// The popover should contain the list of partner_type fields and so
// there should be the "Color index" field
assert.strictEqual(
document.body.querySelector(".o_model_field_selector_popover_item").textContent,
"Color index",
"field selector popover should contain 'Color index' field"
document.body.querySelector(".o_model_field_selector_popover_item_name").textContent,
"Color index"
);
// Clicking on this field should close the popover, then changing the
@@ -231,10 +220,7 @@ QUnit.module("Fields", (hooks) => {
// Saving the form view should show a readonly domain containing the
// "color" field
await clickSave(target);
assert.ok(
target.querySelector(".o_field_domain").textContent.includes("Color index"),
"field selector readonly value should now contain 'Color index'"
);
assert.ok(target.querySelector(".o_field_domain").textContent.includes("Color index"));
});
QUnit.test("using binary field in domain widget", async function (assert) {
@@ -405,16 +391,8 @@ QUnit.module("Fields", (hooks) => {
}
},
});
assert.containsOnce(
target,
".o_field_widget[name='foo']:not(.o_field_empty)",
"there should be a domain field, not considered empty"
);
assert.containsNone(
target,
".o_field_widget[name='foo'] .text-warning",
"should not display that the domain is invalid"
);
assert.containsOnce(target, ".o_field_widget[name='foo']:not(.o_field_empty)");
assert.containsNone(target, ".o_field_widget[name='foo'] .text-warning");
});
QUnit.test("basic domain field: show the selection", async function (assert) {
@@ -726,11 +704,7 @@ QUnit.module("Fields", (hooks) => {
patchWithCleanup(odoo, { debug: true });
let rawDomain = `
[
["date", ">=", datetime.datetime.combine(context_today() + relativedelta(days = -365), datetime.time(0, 0, 0)).to_utc().strftime("%Y-%m-%d %H:%M:%S")]
]
`;
let rawDomain = `[("date", ">=", datetime.datetime.combine(context_today() + relativedelta(days = -365), datetime.time(0, 0, 0)).to_utc().strftime("%Y-%m-%d %H:%M:%S"))]`;
serverData.models.partner.records[0].foo = rawDomain;
serverData.models.partner.fields.bar.type = "char";
serverData.models.partner.records[0].bar = "partner";
@@ -767,11 +741,7 @@ QUnit.module("Fields", (hooks) => {
await doAction(webClient, 1);
assert.strictEqual(target.querySelector(".o_domain_debug_input").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")]
]
`;
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);
@@ -780,6 +750,7 @@ QUnit.module("Fields", (hooks) => {
QUnit.test("domain field: edit through selector (dynamic content)", async function (assert) {
patchWithCleanup(odoo, { debug: true });
patchDate(2020, 8, 5, 0, 0, 0);
let rawDomain = `[("date", ">=", context_today())]`;
serverData.models.partner.records[0].foo = rawDomain;
@@ -818,7 +789,12 @@ QUnit.module("Fields", (hooks) => {
assert.verifySteps(["/web/action/load", "get_views", "read", "search_count", "fields_get"]);
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain);
assert.containsOnce(target, ".o_datetime_input", "there should be a datepicker input");
assert.containsOnce(target, ".o_ds_expr_value", "there should be an expression");
await click(target, ".o_ds_expr_value button");
rawDomain = `[("date", ">=", "2020-09-05")]`;
assert.containsOnce(target, ".o_datetime_input", "there should be a datepicker");
assert.verifySteps(["search_count"]);
// Open and close the datepicker
await click(target, ".o_datetime_input");
@@ -865,13 +841,15 @@ QUnit.module("Fields", (hooks) => {
"should contain an error message saying the model is missing"
);
assert.verifySteps([]);
await editInput(target, ".o_field_widget[name=model_name] input", "test");
assert.notStrictEqual(
target.querySelector('.o_field_widget[name="display_name"]').innerText,
"Select a model to add a filter.",
"should not contain an error message anymore"
await editInput(target, ".o_field_widget[name=model_name] input", "partner");
assert.strictEqual(
target
.querySelector('.o_field_widget[name="display_name"] .o_field_domain_panel')
.innerText.toLowerCase(),
"5 record(s)"
);
assert.verifySteps(["test"]);
assert.verifySteps(["partner"]);
});
QUnit.test("domain field in kanban view", async function (assert) {
@@ -968,11 +946,6 @@ QUnit.module("Fields", (hooks) => {
await click(target, ".modal .o_domain_add_first_node_button");
await editInput(target, ".o_domain_leaf_value_input", "01/01/2002");
await click(target, ".modal-footer .btn-primary");
assert.containsOnce(target, ".o_domain_leaf");
assert.strictEqual(target.querySelector(".o_domain_leaf").textContent, 'ID = "01/01/2002"');
assert.strictEqual(
target.querySelector(".o_field_domain .text-warning").textContent.trim(),
"Invalid domain"
);
assert.containsOnce(target, ".modal", "the domain is invalid: the dialog is not closed");
});
});
@@ -15,8 +15,6 @@ import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
import { browser } from "@web/core/browser/browser";
import { fieldService } from "@web/core/field_service";
import { registry } from "@web/core/registry";
import {
getPickerApplyButton,
getPickerCell,
@@ -230,7 +228,6 @@ QUnit.module("Fields", (hooks) => {
};
setupViewRegistries();
registry.category("services").add("field", fieldService);
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
@@ -13,7 +13,6 @@ import {
triggerHotkey,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { fieldService } from "@web/core/field_service";
const serviceRegistry = registry.category("services");
@@ -595,8 +594,6 @@ QUnit.module("Fields", (hooks) => {
});
QUnit.test("field text with dynamic placeholder", async (assert) => {
registry.category("services").add("field", fieldService);
serverData.models.partner.fields.model_reference_field = {
string: "Model Reference Field",
type: "char",
+1 -3
View File
@@ -5,7 +5,6 @@ import { getFixture, mount, nextTick } from "@web/../tests/helpers/utils";
import { createDebugContext } from "@web/core/debug/debug_context";
import { Dialog } from "@web/core/dialog/dialog";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { View, getDefaultConfig } from "@web/views/view";
import {
@@ -145,8 +144,7 @@ export function setupViewRegistries() {
{ force: true }
);
serviceRegistry.add("router", makeFakeRouterService(), { force: true });
serviceRegistry.add("localization", makeFakeLocalizationService()), { force: true };
serviceRegistry.add("popover", popoverService, { force: true });
serviceRegistry.add("localization", makeFakeLocalizationService());
serviceRegistry.add("company", fakeCompanyService);
}
@@ -58,6 +58,7 @@ import { standaloneAdapter } from "web.OwlCompatibility";
import { overlayService } from "@web/core/overlay/overlay_service";
import { Component, onMounted, xml } from "@odoo/owl";
import { fieldService } from "@web/core/field_service";
const actionRegistry = registry.category("actions");
const serviceRegistry = registry.category("services");
@@ -87,6 +88,7 @@ export function setupWebClientRegistries() {
command: () => commandService,
dialog: () => dialogService,
effect: () => effectService,
field: () => fieldService,
hotkey: () => hotkeyService,
http: () => makeFakeHTTPService(),
legacy_service_provider: () => legacyServiceProvider,