[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:
co-authored by
Michaël Mattiello
parent
381da748c3
commit
c1436ba8ca
@@ -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>
|
||||
|
||||
@@ -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 }}"/>
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user