[IMP] web: domain selector: autocompletion for relational fields

For the relational fields and the operators =,!=,in,not in, we allow
the user to use autocompletion as in a many2one or a x2many field.

Task ID: 3291990

Part-of: odoo/odoo#121688
This commit is contained in:
Mathieu Duckerts-Antoine
2023-07-13 14:47:12 +02:00
parent ff2ee462a5
commit fb9629cb6d
10 changed files with 1076 additions and 135 deletions
@@ -18,13 +18,71 @@ import { getOperatorInfo, toOperator } from "@web/core/domain_selector/domain_se
import {
Editor,
PathEditor,
getDefaultFieldValue,
getEditorInfo,
getDefaultOperator,
getDefaultValue,
getOperatorsInfo,
getEditorInfo,
} from "@web/core/domain_selector/domain_selector_fields";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import { useLoadFieldInfo } from "@web/core/model_field_selector/utils";
import { Expression } from "@web/core/domain_tree";
import { formatValue } from "@web/core/domain_tree";
function collectDifferences(tree, otherTree) {
// some differences shadow the other differences "below":
if (tree.type !== otherTree.type) {
return [{ type: "other" }];
}
if (tree.negate !== otherTree.negate) {
return [{ type: "other" }];
}
if (tree.type === "condition") {
if (formatValue(tree.path) !== formatValue(otherTree.path)) {
return [{ type: "other" }];
}
if (formatValue(tree.value) !== formatValue(otherTree.value)) {
return [{ type: "other" }];
}
if (formatValue(tree.operator) !== formatValue(otherTree.operator)) {
if (tree.operator === "!=" && otherTree.operator === "set") {
return [{ type: "replacement", tree, operator: "set" }];
} else if (tree.operator === "=" && otherTree.operator === "not_set") {
return [{ type: "replacement", tree, operator: "not_set" }];
} else {
return [{ type: "other" }];
}
}
return [];
}
if (tree.value !== otherTree.value) {
return [{ type: "other" }];
}
if (tree.children.length !== otherTree.children.length) {
return [{ type: "other" }];
}
const diffs = [];
for (let i = 0; i < tree.children.length; i++) {
const child = tree.children[i];
const otherChild = otherTree.children[i];
const childDiffs = collectDifferences(child, otherChild);
if (childDiffs.some((d) => d.type !== "replacement")) {
return [{ type: "other" }];
}
diffs.push(...childDiffs);
}
return diffs;
}
function restoreVirtualOperators(tree, otherTree) {
const diffs = collectDifferences(tree, otherTree);
// note that the array diffs is homogeneous:
// we have diffs of the form [], [other], [repl, ..., repl]
if (diffs.some((d) => d.type !== "replacement")) {
return;
}
for (const { tree, operator } of diffs) {
tree.operator = operator;
}
}
export class DomainSelector extends Component {
static template = "web._DomainSelector";
@@ -58,6 +116,7 @@ export class DomainSelector extends Component {
this.loadDisplayNames = useLoadDisplayNames();
this.loadFieldInfo = useLoadFieldInfo();
this.tree = null;
this.previousTree = null;
onWillStart(() => this.onPropsUpdated(this.props));
onWillUpdateProps((np) => this.onPropsUpdated(np));
}
@@ -91,6 +150,7 @@ export class DomainSelector extends Component {
if (!isSupported) {
this.tree = null;
this.previousTree = null;
return;
}
@@ -116,9 +176,16 @@ export class DomainSelector extends Component {
this.getFieldDef.bind(this),
options
).children[0];
if (this.previousTree) {
// find "first" difference
restoreVirtualOperators(this.tree, this.previousTree);
this.previousTree = null;
}
}
notifyChanges() {
this.previousTree = cloneTree(this.tree);
const domain = this.tree ? buildDomain(this.tree) : `[]`;
this.props.update(domain);
}
@@ -133,10 +200,6 @@ export class DomainSelector extends Component {
return null;
}
getDefaultFieldValue(path, operator) {
return getDefaultFieldValue(this.getFieldDef(path), operator);
}
async loadFieldDefs(resModel, paths) {
const promises = [];
const fieldDefs = {};
@@ -216,18 +279,26 @@ export class DomainSelector extends Component {
Object.assign(node, this.createNewLeaf());
} else {
node.path = path;
const operatorInfo = getOperatorsInfo(fieldDef)[0];
node.operator = operatorInfo.operator;
node.value = getDefaultFieldValue(fieldDef, node.operator);
node.operator = getDefaultOperator(fieldDef);
node.value = getDefaultValue(fieldDef, node.operator);
}
this.notifyChanges();
}
updateLeafOperator(node, operatorKey) {
const [operator, negate] = toOperator(operatorKey);
const previousOperatorInfo = getOperatorInfo(node.operator);
const previousNode = node ? cloneTree(node) : null;
const [operator, negate] = toOperator(operatorKey);
node.negate = negate;
node.operator = operator;
const editorInfo = this.getEditorInfo(node);
if (!editorInfo.isSupported(node.value) || editorInfo.shouldResetValue?.(node.value)) {
node.value = getDefaultValue(this.getFieldDef(node.path), node.operator);
}
const operatorInfo = getOperatorInfo(operator);
if (previousOperatorInfo.valueCount !== operatorInfo.valueCount) {
switch (operatorInfo.valueCount) {
@@ -243,17 +314,28 @@ export class DomainSelector extends Component {
}
// binary operator with a non array value
case 1: {
node.value = this.getDefaultFieldValue(node.path, operator);
node.value = getDefaultValue(this.getFieldDef(node.path), node.operator);
break;
}
// binary operator with a fixed sized array value
default: {
const defaultValue = this.getDefaultFieldValue(node.path, operator);
const defaultValue = getDefaultValue(
this.getFieldDef(node.path),
node.operator
);
node.value = Array(operatorInfo.valueCount).fill(defaultValue);
break;
}
}
}
if (buildDomain(node) === buildDomain(previousNode)) {
// no interesting changes for parent (only possible domain formatting/rewriting)
// this means that parent might not render the domain selector
// but we need to udpate editors
this.render();
}
this.notifyChanges();
}
@@ -262,14 +344,6 @@ export class DomainSelector extends Component {
this.notifyChanges();
}
isExprValue(value) {
return value instanceof Expression;
}
removeExprValue(node) {
this.updateLeafValue(node, this.getDefaultFieldValue(node.path, node.operator));
}
onDebugValueChange(value) {
return this.props.update(value, true);
}
@@ -168,9 +168,9 @@
</t>
</select>
</div>
<t t-if="getOperatorInfo(node).valueCount">
<t t-if="getOperatorInfo(node).valueCount !== 0">
<div class="o_ds_value_cell">
<Editor info="getEditorInfo(node)" value="node.value" update="(value) => this.updateLeafValue(node, value)" fieldDef="getFieldDef(node.path)" onExprRemove="() => this.removeExprValue(node)" />
<Editor info="getEditorInfo(node)" value="node.value" update="(value) => this.updateLeafValue(node, value)" fieldDef="getFieldDef(node.path)"/>
</div>
</t>
@@ -0,0 +1,252 @@
/** @odoo-module **/
import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl";
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
import { _t } from "@web/core/l10n/translation";
import { formatAST, toPyValue } from "@web/core/py_js/py_utils";
import { registry } from "@web/core/registry";
import { TagsList } from "@web/core/tags_list/tags_list";
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
import { sprintf } from "@web/core/utils/strings";
import { Expression } from "@web/core/domain_tree";
const SEARCH_LIMIT = 7;
const SEARCH_MORE_LIMIT = 320;
const isId = (val) => Number.isInteger(val) && val >= 1;
class AutoCompleteWithSources extends Component {
static props = {
resModel: String,
update: Function,
multiSelect: Boolean,
getIds: Function,
value: String,
className: { type: String, optional: true },
fieldString: { type: String, optional: true },
};
static components = { AutoComplete };
static template = "web.DomainSelector.AutoCompleteWithSources";
setup() {
this.orm = useService("orm");
this.nameService = useService("name");
this.addDialog = useOwnedDialogs();
this.sources = [
{
placeholder: _t("Loading..."),
options: this.loadOptionsSource.bind(this),
},
];
}
addNames(nameGets) {
const displayNames = {};
for (const [id, label] of nameGets) {
displayNames[id] = label.split("\n")[0];
}
this.nameService.addDisplayNames(this.props.resModel, displayNames);
}
getIds() {
return this.props.getIds();
}
async loadOptionsSource(name) {
if (this.lastProm) {
this.lastProm.abort(false);
}
this.lastProm = this.search(name, SEARCH_LIMIT + 1);
const nameGets = await this.lastProm;
this.addNames(nameGets);
const options = nameGets.map(([value, label]) => ({ value, label: label.split("\n")[0] }));
if (SEARCH_LIMIT < nameGets.length) {
options.push({
label: _t("Search More..."),
action: this.onSearchMore.bind(this, name),
classList: "o_m2o_dropdown_option",
});
}
if (options.length === 0) {
options.push({ label: _t("(no result)"), unselectable: true });
}
return options;
}
async onSearchMore(name) {
const { fieldString, multiSelect, resModel } = this.props;
let operator;
const ids = [];
if (name) {
const nameGets = await this.search(name, SEARCH_MORE_LIMIT);
this.addNames(nameGets);
operator = "in";
ids.push(...nameGets.map((nameGet) => nameGet[0]));
} else {
operator = "not in";
ids.push(...this.getIds());
}
const dynamicFilters = ids.length
? [
{
description: sprintf(_t("Quick search: %s"), name),
domain: [["id", operator, ids]],
},
]
: undefined;
// fine for now but we don't like this kind of dependence of core to views
const SelectCreateDialog = registry.category("dialogs").get("select_create");
this.addDialog(SelectCreateDialog, {
title: sprintf(_t("Search: %s"), fieldString),
dynamicFilters,
resModel,
noCreate: true,
multiSelect,
onSelected: (resId) => {
const resIds = Array.isArray(resId) ? resId : [resId];
this.props.update([...resIds]);
},
});
}
onSelect({ value: resId, action }, params) {
if (action) {
return action(params);
}
this.props.update([resId]);
}
search(name, limit) {
const ids = this.getIds();
const domain = [["id", "not in", ids]];
return this.orm.call(this.props.resModel, "name_search", [], {
name,
args: domain,
limit,
});
}
onChange({ inputValue }) {
if (!inputValue.length) {
this.props.update([]);
}
}
}
const getFormat = (val, displayNames) => {
let text;
let colorIndex;
if (isId(val)) {
text =
typeof displayNames[val] === "string"
? displayNames[val]
: sprintf(_t(`Inaccessible/missing record ID: %s`), val);
colorIndex = typeof displayNames[val] === "string" ? 0 : 2; // 0 = grey, 2 = orange
} else {
text =
val instanceof Expression
? String(val)
: sprintf(_t(`Invalid record ID: %s`), formatAST(toPyValue(val)));
colorIndex = val instanceof Expression ? 2 : 1; // 1 = red
}
return { text, colorIndex };
};
export class DomainSelectorAutocomplete extends Component {
static props = {
resModel: String,
update: Function,
value: true,
fieldString: { type: String, optional: true },
};
static components = { AutoCompleteWithSources, TagsList };
static template = "web.DomainSelector.DomainSelectorAutocomplete";
setup() {
this.nameService = useService("name");
onWillStart(() => this.computeDerivedParams());
onWillUpdateProps((nextProps) => this.computeDerivedParams(nextProps));
}
async computeDerivedParams(props = this.props) {
const displayNames = await this.getDisplayNames(props);
this.tags = this.getTags(props, displayNames);
}
async getDisplayNames(props) {
const ids = this.getIds(props);
return this.nameService.loadDisplayNames(props.resModel, ids);
}
getIds(props = this.props) {
return props.value.filter((val) => isId(val));
}
getTags(props, displayNames) {
return props.value.map((val, index) => {
const { text, colorIndex } = getFormat(val, displayNames);
return {
text,
colorIndex,
onDelete: () => {
this.props.update([
...this.props.value.slice(0, index),
...this.props.value.slice(index + 1),
]);
},
};
});
}
update(resIds) {
this.props.update([...this.props.value, ...resIds]);
}
}
export class DomainSelectorSingleAutocomplete extends Component {
static props = {
resModel: String,
update: Function,
value: true,
fieldString: { type: String, optional: true },
};
static components = { AutoCompleteWithSources };
static template = "web.DomainSelector.DomainSelectorSingleAutocomplete";
setup() {
this.nameService = useService("name");
onWillStart(() => this.computeDerivedParams());
onWillUpdateProps((nextProps) => this.computeDerivedParams(nextProps));
}
async computeDerivedParams(props = this.props) {
const displayNames = await this.getDisplayNames(props);
this.displayName = this.getDisplayName(props, displayNames);
}
async getDisplayNames(props) {
const ids = this.getIds(props);
return this.nameService.loadDisplayNames(props.resModel, ids);
}
getDisplayName(props = this.props, displayNames) {
const { value } = props;
if (value === false) {
return "";
}
const { text } = getFormat(value, displayNames);
return text;
}
getIds(props = this.props) {
if (isId(props.value)) {
return [props.value];
}
return [];
}
update(resIds) {
this.props.update(resIds[0] || false);
this.render(true);
}
}
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DomainSelector.AutoCompleteWithSources" owl="1" >
<AutoComplete
value="props.value"
autoSelect="true"
resetOnSelect="props.multiSelect"
class="props.className"
sources="sources"
onSelect.bind="onSelect"
onChange.bind="onChange"
/>
</t>
<t t-name="web.DomainSelector.DomainSelectorAutocomplete" owl="1" >
<div class="d-flex flex-wrap h-100 gap-1">
<TagsList tags="tags"/>
<AutoCompleteWithSources
resModel="props.resModel"
value="''"
className="'o_input o_domain_leaf_value_input flex-grow-1'"
fieldString="props.fieldString"
multiSelect="true"
getIds.bind="getIds"
update.bind="update"
/>
</div>
</t>
<t t-name="web.DomainSelector.DomainSelectorSingleAutocomplete" owl="1" >
<AutoCompleteWithSources
resModel="props.resModel"
value="displayName"
className="'o_input o_domain_leaf_value_input h-100'"
fieldString="props.fieldString"
multiSelect="false"
getIds="() => []"
update.bind="update"
/>
</t>
</templates>
@@ -16,17 +16,24 @@ import { DateTimeInput } from "../datetime/datetime_input";
import { TagsList } from "@web/core/tags_list/tags_list";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import { Expression, formatValue } from "@web/core/domain_tree";
import {
DomainSelectorAutocomplete,
DomainSelectorSingleAutocomplete,
} from "./domain_selector_autocomplete";
const { DateTime } = luxon;
const identity = (x) => x;
const isString = (value) => typeof value === "string";
// ============================================================================
export class Editor extends Component {
static props = ["info", "value", "update", "fieldDef?", "onExprRemove"];
static props = ["info", "value", "update", "fieldDef?"];
static template = "web.DomainSelector.Editor";
get isExprValue() {
return this.props.value instanceof Expression;
get stringifiedValue() {
return formatValue(this.props.value);
}
}
@@ -94,14 +101,10 @@ class TagInput extends Component {
}
class Range extends Component {
static props = ["value", "update", "component", "subProps", "getValueOnExprRemove"];
static props = ["value", "update", "editorInfo", "fieldDef"];
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;
@@ -115,10 +118,6 @@ class Range extends Component {
getUpdater(index) {
return (newValue) => this.update(index, newValue);
}
getExprRemover(index) {
return () => this.update(index, this.props.getValueOnExprRemove());
}
}
// ============================================================================
@@ -133,10 +132,54 @@ function parseValue(fieldType, value) {
}
}
function makeEditor(component, props) {
function makeEditor(component, { props, isSupported, defaultValue } = {}) {
return {
component,
extractProps: props || (({ value, update }) => ({ value, update })),
isSupported: isSupported || ((value) => !(value instanceof Expression)),
defaultValue:
defaultValue ||
((fieldDef, operator) => {
switch (operator) {
case "in":
case "not in":
return [];
case "set":
case "not_set":
case "child_of":
case "parent_of":
return false;
case "is":
case "is_not":
return true;
case "=like":
case "=ilike":
case "like":
case "not like":
case "ilike":
case "not ilike":
return "";
default: {
let val;
switch (fieldDef?.type) {
case "integer":
case "float":
case "monetary":
val = 1;
break;
case "boolean":
val = false;
break;
default:
val = "";
}
if (operator === "between") {
return [val, val];
}
return val;
}
}
}),
};
}
@@ -179,47 +222,53 @@ const DEFAULT = {
defaultValue: () => "",
};
const SET_NOT_SET_EDITOR = {
component: null,
extractProps: identity,
isSupported: (value) => value === false,
};
// ----------------------------------------------------------------------------
const BOOLEAN = {
operators: ["is", "is_not"],
editors: {
default: makeEditor(Select, ({ value, update }) => ({
value,
update,
options: [
[true, _lt("set")],
[false, _lt("not set")],
],
})),
default: makeEditor(Select, {
props: ({ value, update }) => ({
value,
update,
options: [
[true, _lt("set")],
[false, _lt("not set")],
],
}),
}),
},
defaultValue: () => true,
};
// ----------------------------------------------------------------------------
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_EDITOR_BETWEEN = makeEditor(Range, {
props: ({ value, update, fieldDef }) => ({
value,
update,
editorInfo: getEditorInfo(fieldDef),
fieldDef,
}),
}));
});
const DATETIME = {
operators: ["=", "!=", ">", ">=", "<", "<=", "between", "set", "not_set"],
editors: {
default: makeEditor(DateTimeInput, ({ value, update, fieldDef }) => ({
value: genericDeserializeDate(fieldDef.type, value),
type: fieldDef.type,
onApply: (value) =>
update(value ? genericSerializeDate(fieldDef.type, value) : DateTime.local()),
})),
default: makeEditor(DateTimeInput, {
props: ({ value, update, fieldDef }) => ({
value: genericDeserializeDate(fieldDef.type, value),
type: fieldDef.type,
onApply: (value) =>
update(value ? genericSerializeDate(fieldDef.type, value) : DateTime.local()),
}),
}),
between: DATETIME_EDITOR_BETWEEN,
},
defaultValue: ({ type }) => genericSerializeDate(type, DateTime.local()),
@@ -242,56 +291,126 @@ const TEXT = {
const NUMBER = {
operators: ["=", "!=", ">", ">=", "<", "<=", "between", "ilike", "not ilike", "set", "not_set"],
editors: {
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 }) => ({
default: makeEditor(Input, {
props: ({ value, update, fieldDef }) => ({
value: String(value),
update: (newValue) => update(parseValue(fieldDef.type, newValue)),
update: (value) => update(parseValue(fieldDef.type, value)),
}),
})),
}),
between: makeEditor(Range, {
props: ({ value, update, fieldDef }) => ({
value,
update,
editorInfo: getEditorInfo(fieldDef),
fieldDef,
}),
}),
},
defaultValue: () => 1,
};
// ----------------------------------------------------------------------------
const RELATIONAL_EDITOR_EQUALITY = makeEditor(Input, ({ value, update }) => ({
value: `${value}`,
update: (value) => update(parseValue("integer", value)),
}));
const isId = (value) => Number.isInteger(value) && value >= 1;
const RELATIONAL = {
operators: ["=", "!=", "ilike", "not ilike", "set", "not_set"],
editors: {
default: makeEditor(Input),
equal: RELATIONAL_EDITOR_EQUALITY,
"!=": RELATIONAL_EDITOR_EQUALITY,
const RELATIONAL_EDITOR_EQUALITY = makeEditor(DomainSelectorSingleAutocomplete, {
props: ({ value, update, fieldDef }) => {
return {
resModel: fieldDef.relation,
fieldString: fieldDef.string,
update,
value,
};
},
isSupported: () => true,
});
RELATIONAL_EDITOR_EQUALITY.shouldResetValue = (value) => value !== false || !isId(value);
const RELATIONAL_EDITOR_IN = makeEditor(DomainSelectorAutocomplete, {
props: ({ value, update, fieldDef }) => {
return {
resModel: fieldDef.relation,
fieldString: fieldDef.string,
update: (value) => update([...new Set(value)]),
value: Array.isArray(value) ? [...new Set(value)] : value,
};
},
isSupported: (value) => Array.isArray(value),
});
const MANY2ONE = {
operators: ["in", "not in", "=", "!=", "ilike", "not ilike", "set", "not_set"],
editors: {
default: makeEditor(Input, { isSupported: isString }), // ilike, not ilike + others (found in original domain)
"=": RELATIONAL_EDITOR_EQUALITY,
"!=": RELATIONAL_EDITOR_EQUALITY,
in: RELATIONAL_EDITOR_IN,
"not in": RELATIONAL_EDITOR_IN,
set: SET_NOT_SET_EDITOR,
not_set: SET_NOT_SET_EDITOR,
},
defaultValue: (_, operator) => {
switch (operator) {
case "in":
case "not in":
return [];
case "=":
case "!=":
case "set":
case "not_set":
return false;
default:
return "";
}
},
};
const X2MANY = {
operators: ["in", "not in", "=", "!=", "ilike", "not ilike", "set", "not_set"],
editors: {
default: makeEditor(Input, { isSupported: isString }), // ilike, not ilike + others (found in original domain)
in: RELATIONAL_EDITOR_IN,
"not in": RELATIONAL_EDITOR_IN,
"=": RELATIONAL_EDITOR_IN,
"!=": RELATIONAL_EDITOR_IN,
set: SET_NOT_SET_EDITOR,
not_set: SET_NOT_SET_EDITOR,
},
defaultValue: (_, operator) => {
switch (operator) {
case "in":
case "not in":
case "=":
case "!=":
return [];
case "set":
case "not_set":
return false;
default:
return "";
}
},
defaultValue: ({ type }) => (type === "many2one" ? 1 : []),
};
// ----------------------------------------------------------------------------
const SELECTION_EDITOR_IN = makeEditor(Input, ({ value, update }) => ({
value: formatAST(toPyValue(value)),
update: (value) => update(evaluateExpr(value)),
}));
const SELECTION_EDITOR_IN = makeEditor(Input, {
props: ({ value, update }) => ({
value: formatAST(toPyValue(value)),
update: (value) => update(evaluateExpr(value)),
}),
});
const SELECTION = {
operators: ["=", "!=", "in", "not in", "set", "not_set"],
editors: {
default: makeEditor(Select, ({ value, update, fieldDef }) => ({
value,
update,
options: fieldDef.selection || [],
})),
default: makeEditor(Select, {
props: ({ value, update, fieldDef }) => ({
value,
update,
options: fieldDef.selection || [],
}),
}),
in: SELECTION_EDITOR_IN,
"not in": SELECTION_EDITOR_IN,
},
@@ -308,11 +427,13 @@ const PROPERTIES = {
const PROPERTIES_SELECTION = {
operators: ["=", "!=", "set", "not_set"],
editors: {
default: makeEditor(Select, ({ value, update, fieldDef }) => ({
value,
update,
options: fieldDef.selection || [],
})),
default: makeEditor(Select, {
props: ({ value, update, fieldDef }) => ({
value,
update,
options: fieldDef.selection || [],
}),
}),
},
defaultValue: ({ selection }) => selection?.[0]?.[0] ?? false,
};
@@ -344,10 +465,10 @@ export const FIELD_DESCRIPTIONS = {
html: TEXT,
integer: NUMBER,
json: JSON_FIELD,
many2many: RELATIONAL,
many2one: RELATIONAL,
many2many: X2MANY,
many2one: MANY2ONE,
monetary: NUMBER,
one2many: RELATIONAL,
one2many: X2MANY,
properties: PROPERTIES,
properties_definition: PROPERTIES,
selection: SELECTION,
@@ -375,7 +496,9 @@ export function getFieldInfo(fieldDef) {
export function getEditorInfo(fieldDef, operator) {
const descr = getFieldInfo(fieldDef);
return descr.editors[operator] || descr.editors.default;
const editorInfo = descr.editors[operator] || descr.editors.default;
editorInfo.defaultValue = () => getDefaultValue(fieldDef, operator);
return editorInfo;
}
export function getOperatorsInfo(fieldDef) {
@@ -383,12 +506,12 @@ export function getOperatorsInfo(fieldDef) {
return selectOperators(descr.operators);
}
export function getDefaultFieldValue(fieldDef, operator) {
export function getDefaultValue(fieldDef, operator) {
const descr = getFieldInfo(fieldDef);
return descr.defaultValue(fieldDef, operator);
}
export function getDefaultOperator(fieldDef) {
const [firstOperator] = getOperatorsInfo(fieldDef);
return firstOperator;
const descr = getFieldInfo(fieldDef);
return descr.operators[0];
}
@@ -2,18 +2,18 @@
<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" />
<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()">
<t t-if="props.info.isSupported(props.value)">
<t t-component="props.info.component" t-props="props.info.extractProps({ value: props.value, update: props.update, fieldDef: props.fieldDef })" />
</t>
<t t-else="">
<div class="o_ds_expr_value o_input d-flex align-items-center p-0">
<span class="ms-2" t-esc="stringifiedValue" />
<i role="alert" class="ms-auto fa fa-exclamation-triangle text-warning" title="Value not supported" />
<button class="btn btn-link p-1" title="Clear" t-on-click="() => this.props.update(props.info.defaultValue())">
<i class="fa fa-times" />
</button>
</div>
</t>
<t t-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.PathEditor" owl="1">
@@ -52,9 +52,9 @@
<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)" />
<Editor info="props.editorInfo" value="getValue(0)" update="getUpdater(0)" fieldDef="props.fieldDef" />
<i class="fa fa-long-arrow-right mx-2" aria-label="Arrow icon" title="Arrow" />
<Editor info="editorInfo" value="getValue(1)" update="getUpdater(1)" onExprRemove="getExprRemover(1)" />
<Editor info="props.editorInfo" value="getValue(1)" update="getUpdater(1)" fieldDef="props.fieldDef" />
</div>
</t>
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
import { getDefaultFieldValue, getDefaultOperator } from "./domain_selector_fields";
import { getDefaultValue, getDefaultOperator } from "./domain_selector_fields";
import { useService } from "@web/core/utils/hooks";
import { Expression, toValue, toDomain, toTree } from "@web/core/domain_tree";
import { _t } from "@web/core/l10n/translation";
@@ -20,7 +20,7 @@ export function createVirtualOperators(tree, getFieldDef) {
const fieldDef = getFieldDef(path);
if (fieldDef?.type === "boolean") {
return { ...tree, operator: operator === "=" ? "is" : "is_not" };
} else if (value === false) {
} else if (fieldDef?.type !== "many2one" && value === false) {
return { ...tree, operator: operator === "=" ? "not_set" : "set" };
}
}
@@ -255,13 +255,13 @@ function getDefaultDomain(fieldDefs) {
for (const name of SPECIAL_FIELDS) {
fieldDef = fieldDefs[name];
if (fieldDef) {
const operatorInfo = getDefaultOperator(fieldDef);
const value = getDefaultFieldValue(fieldDef, operatorInfo.operator);
const operator = getDefaultOperator(fieldDef);
const value = getDefaultValue(fieldDef, operator);
return buildDomain({
type: "condition",
negate: false,
path: fieldDef.name,
operator: operatorInfo.operator,
operator,
value,
});
}
@@ -9,6 +9,7 @@ import { escape } from "@web/core/utils/strings";
import { FormViewDialog } from "./form_view_dialog";
import { Component, markup, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
export class SelectCreateDialog extends Component {
setup() {
@@ -108,3 +109,5 @@ SelectCreateDialog.defaultProps = {
domain: [],
context: {},
};
registry.category("dialogs").add("select_create", SelectCreateDialog);
@@ -10,6 +10,7 @@ import {
nextTick,
patchDate,
patchTimeZone,
patchWithCleanup,
triggerEvent,
} from "../helpers/utils";
import { Component, useState, xml } from "@odoo/owl";
@@ -28,15 +29,40 @@ import { uiService } from "@web/core/ui/ui_service";
import { getPickerApplyButton, getPickerCell } from "./datetime/datetime_test_helpers";
import { openModelFieldSelectorPopover } from "./model_field_selector_tests";
import { nameService } from "@web/core/name_service";
import { dialogService } from "@web/core/dialog/dialog_service";
import { browser } from "@web/core/browser/browser";
let serverData;
let target;
function addProductIds() {
serverData.models.partner.fields.product_ids = {
string: "Products",
type: "many2many",
relation: "product",
searchable: true,
};
}
async function selectOperator(el, operator, index = 0) {
const select = el.querySelectorAll("select.o_domain_leaf_operator_select")[index];
await editSelect(select, null, operator);
}
function getOperatorOptions(el, index = 0) {
const select = el.querySelectorAll("select.o_domain_leaf_operator_select")[index];
return [...select.options].map((o) => o.label);
}
function getSelectedOperator(el, index = 0) {
const select = el.querySelectorAll("select.o_domain_leaf_operator_select")[index];
return select.options[select.selectedIndex].label;
}
function getAutocompletValue(target, index = 0) {
return target.querySelectorAll(".o_ds_value_cell .o-autocomplete--input")[index].value;
}
async function mountComponent(Component, params = {}) {
const env = await makeTestEnv({ serverData, mockRPC: params.mockRPC });
await mount(MainComponentsContainer, target, { env });
@@ -132,6 +158,7 @@ QUnit.module("Components", (hooks) => {
registry.category("services").add("localization", makeFakeLocalizationService());
registry.category("services").add("field", fieldService);
registry.category("services").add("name", nameService);
registry.category("services").add("dialog", dialogService);
target = getFixture();
});
@@ -338,7 +365,7 @@ QUnit.module("Components", (hooks) => {
await makeDomainSelector({
domain: `[("int", "=", id)]`,
update(domain) {
assert.strictEqual(domain, `[("int", "<", id)]`);
assert.strictEqual(domain, `[("int", "<", 1)]`);
},
});
@@ -351,29 +378,29 @@ QUnit.module("Components", (hooks) => {
await editSelect(target, ".o_domain_leaf_operator_select", "<");
assert.strictEqual(target.querySelector(".o_model_field_selector").innerText, "Integer");
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "<");
assert.containsNone(target, ".o_ds_value_cell input");
assert.containsOnce(target, ".o_ds_expr_value");
assert.strictEqual(target.querySelector(".o_ds_expr_value").textContent, "id");
assert.containsOnce(target, ".o_ds_value_cell input");
assert.containsNone(target, ".o_ds_expr_value");
assert.strictEqual(target.querySelector(".o_ds_value_cell input").value, "1");
});
QUnit.test("building a domain with a m2o without following the relation", async (assert) => {
assert.expect(1);
await makeDomainSelector({
domain: `[("product_id", "ilike", 1)]`,
isDebugMode: true,
update: (domain) => {
assert.strictEqual(
domain,
`[("product_id", "ilike", "pad")]`,
"string should have been allowed as m2o value"
);
assert.step(domain);
},
});
assert.verifySteps([]);
assert.containsOnce(target, ".o_ds_expr_value");
await click(target, ".o_ds_expr_value button");
assert.verifySteps([`[("product_id", "ilike", "")]`]);
const input = target.querySelector(".o_domain_leaf_value_input");
input.value = "pad";
await triggerEvent(input, null, "change");
assert.verifySteps([`[("product_id", "ilike", "pad")]`]);
});
QUnit.test("editing a domain with `parent` key", async (assert) => {
@@ -514,7 +541,7 @@ QUnit.module("Components", (hooks) => {
QUnit.test("operator fallback (edit mode)", async (assert) => {
OPERATOR_DESCRIPTIONS.test = {
label: "test",
valueMode: "none",
valueCount: 0,
};
registerCleanup(() => {
delete OPERATOR_DESCRIPTIONS.test;
@@ -1592,11 +1619,11 @@ QUnit.module("Components", (hooks) => {
},
{
domain: `[("product_id", "=", False)]`,
text: "Product is not set ",
text: "Product = false",
},
{
domain: `[("product_id", "!=", False)]`,
text: "Product is set ",
text: "Product != false",
},
{
domain: `[("product_id", "in", [])]`,
@@ -1633,4 +1660,422 @@ QUnit.module("Components", (hooks) => {
assert.strictEqual(target.querySelector(".o_domain_leaf").textContent, text);
}
});
QUnit.test("many2one field operators (edit)", async (assert) => {
await makeDomainSelector({
domain: `[("product_id", "=", false)]`,
});
assert.deepEqual(getOperatorOptions(target), [
"is in",
"is not in",
"=",
"!=",
"contains",
"does not contain",
"is set",
"is not set",
]);
});
QUnit.test("many2one field: operator switch (edit)", async (assert) => {
await makeDomainSelector({
domain: `[("product_id", "=", false)]`,
update(domain) {
assert.step(domain);
},
});
await selectOperator(target, "in");
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")),
[]
);
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([`[("product_id", "in", [])]`]);
await selectOperator(target, "=");
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([`[("product_id", "=", False)]`]);
await selectOperator(target, "not in");
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")),
[]
);
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([`[("product_id", "not in", [])]`]);
await selectOperator(target, "ilike");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "");
assert.verifySteps([`[("product_id", "ilike", "")]`]);
await selectOperator(target, "!=");
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([`[("product_id", "!=", False)]`]);
await selectOperator(target, "not ilike");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "");
assert.verifySteps([`[("product_id", "not ilike", "")]`]);
});
QUnit.test("many2one field and operator =/!= (edit)", async (assert) => {
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
await makeDomainSelector({
domain: `[("product_id", "=", False)]`,
update(domain) {
assert.step(domain);
},
});
assert.strictEqual(getSelectedOperator(target), "=");
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([]);
assert.containsNone(target, ".dropdown-menu");
await editInput(target, ".o-autocomplete--input", "xph");
assert.containsOnce(target, ".dropdown-menu");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".dropdown-menu li")), [
"xphone",
]);
assert.strictEqual(getSelectedOperator(target), "=");
assert.strictEqual(getAutocompletValue(target), "xph");
await click(target, ".dropdown-menu li");
assert.strictEqual(getSelectedOperator(target), "=");
assert.strictEqual(getAutocompletValue(target), "xphone");
assert.verifySteps([`[("product_id", "=", 37)]`]);
assert.containsNone(target, ".dropdown-menu");
await editInput(target, ".o-autocomplete--input", "");
assert.strictEqual(getSelectedOperator(target), "=");
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([`[("product_id", "=", False)]`]);
await selectOperator(target, "!=");
assert.strictEqual(getSelectedOperator(target), "!=");
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([`[("product_id", "!=", False)]`]);
await editInput(target, ".o-autocomplete--input", "xpa");
await click(target, ".dropdown-menu li");
assert.strictEqual(getSelectedOperator(target), "!=");
assert.strictEqual(getAutocompletValue(target), "xpad");
assert.verifySteps([`[("product_id", "!=", 41)]`]);
});
QUnit.test("many2one field and operator in/not in (edit)", async (assert) => {
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
await makeDomainSelector({
domain: `[("product_id", "in", [37])]`,
update(domain) {
assert.step(domain);
},
});
assert.strictEqual(getSelectedOperator(target), "is in");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")), [
"xphone",
]);
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([]);
assert.containsNone(target, ".dropdown-menu");
await editInput(target, ".o-autocomplete--input", "x");
assert.containsOnce(target, ".dropdown-menu");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".dropdown-menu li")), [
"xpad",
]);
await click(target, ".dropdown-menu li");
assert.verifySteps([`[("product_id", "in", [37, 41])]`]);
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")), [
"xphone",
"xpad",
]);
assert.strictEqual(getAutocompletValue(target), "");
await selectOperator(target, "not in");
assert.strictEqual(getSelectedOperator(target), "is not in");
assert.strictEqual(getAutocompletValue(target), "");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")), [
"xphone",
"xpad",
]);
assert.verifySteps([`[("product_id", "not in", [37, 41])]`]);
await click(target.querySelector(".o_tag .o_delete"));
assert.strictEqual(getSelectedOperator(target), "is not in");
assert.strictEqual(getAutocompletValue(target), "");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")), [
"xpad",
]);
assert.verifySteps([`[("product_id", "not in", [41])]`]);
});
QUnit.test("many2one field and operator ilike/not ilike (edit)", async (assert) => {
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
await makeDomainSelector({
domain: `[("product_id", "ilike", "abc")]`,
update(domain) {
assert.step(domain);
},
});
assert.strictEqual(getSelectedOperator(target), "contains");
assert.containsNone(target, ".o-autocomplete--input");
assert.containsOnce(target, ".o_ds_value_cell .o_input");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "abc");
assert.verifySteps([]);
await editInput(target, ".o_ds_value_cell .o_input", "def");
assert.strictEqual(getSelectedOperator(target), "contains");
assert.containsOnce(target, ".o_ds_value_cell .o_input");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "def");
assert.verifySteps([`[("product_id", "ilike", "def")]`]);
await selectOperator(target, "not ilike");
assert.strictEqual(getSelectedOperator(target), "does not contain");
assert.containsOnce(target, ".o_ds_value_cell .o_input");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "def");
assert.verifySteps([`[("product_id", "not ilike", "def")]`]);
});
QUnit.test("many2many field and operator set/not set (edit)", async (assert) => {
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
await makeDomainSelector({
domain: `[("product_id", "=", False)]`,
update(domain) {
assert.step(domain);
},
});
assert.strictEqual(getSelectedOperator(target), "=");
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([]);
await selectOperator(target, "not_set");
assert.strictEqual(getSelectedOperator(target), "is not set");
assert.containsNone(target, ".o_ds_value_cell");
assert.verifySteps([`[("product_id", "=", False)]`]);
await selectOperator(target, "set");
assert.strictEqual(getSelectedOperator(target), "is set");
assert.containsNone(target, ".o_ds_value_cell");
assert.verifySteps([`[("product_id", "!=", False)]`]);
await selectOperator(target, "!=");
assert.strictEqual(getSelectedOperator(target), "!=");
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([`[("product_id", "!=", False)]`]);
});
QUnit.test("many2many field: clone a set/not set condition", async (assert) => {
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
await makeDomainSelector({
domain: `[("product_id", "=", False)]`,
update(domain) {
assert.step(domain);
},
});
assert.strictEqual(getSelectedOperator(target), "=");
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([]);
await selectOperator(target, "not_set");
assert.strictEqual(getSelectedOperator(target), "is not set");
assert.containsNone(target, ".o_ds_value_cell");
assert.verifySteps([`[("product_id", "=", False)]`]);
assert.containsOnce(target, ".o_domain_leaf");
await click(target, ".o_domain_add_node_button .fa-plus");
assert.containsN(target, ".o_domain_leaf", 2);
assert.strictEqual(getSelectedOperator(target), "is not set");
assert.strictEqual(getSelectedOperator(target, 1), "is not set");
assert.verifySteps([`["&", ("product_id", "=", False), ("product_id", "=", False)]`]);
});
QUnit.test("x2many field operators (edit)", async (assert) => {
addProductIds();
await makeDomainSelector({
domain: `[("product_ids", "=", false)]`,
});
assert.deepEqual(getOperatorOptions(target), [
"is in",
"is not in",
"=",
"!=",
"contains",
"does not contain",
"is set",
"is not set",
]);
});
QUnit.test("x2many field: operator switch (edit)", async (assert) => {
addProductIds();
await makeDomainSelector({
domain: `[("product_ids", "=", false)]`,
update(domain) {
assert.step(domain);
},
});
await selectOperator(target, "in");
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")),
[]
);
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([`[("product_ids", "in", [])]`]);
await selectOperator(target, "=");
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")),
[]
);
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([`[("product_ids", "=", [])]`]);
await selectOperator(target, "not in");
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")),
[]
);
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([`[("product_ids", "not in", [])]`]);
await selectOperator(target, "ilike");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "");
assert.verifySteps([`[("product_ids", "ilike", "")]`]);
await selectOperator(target, "not_set");
assert.containsNone(target, ".o_ds_value_cell");
assert.verifySteps([`[("product_ids", "=", False)]`]);
await selectOperator(target, "!=");
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")),
[]
);
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([`[("product_ids", "!=", [])]`]);
await selectOperator(target, "not ilike");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "");
assert.verifySteps([`[("product_ids", "not ilike", "")]`]);
await selectOperator(target, "set");
assert.containsNone(target, ".o_ds_value_cell");
assert.verifySteps([`[("product_ids", "!=", False)]`]);
});
QUnit.test("many2many field and operator =/!=/in/not in (edit)", async (assert) => {
addProductIds();
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
await makeDomainSelector({
domain: `[("product_ids", "in", [37])]`,
update(domain) {
assert.step(domain);
},
});
assert.strictEqual(getSelectedOperator(target), "is in");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")), [
"xphone",
]);
assert.strictEqual(getAutocompletValue(target), "");
assert.verifySteps([]);
assert.containsNone(target, ".dropdown-menu");
await editInput(target, ".o-autocomplete--input", "x");
assert.containsOnce(target, ".dropdown-menu");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".dropdown-menu li")), [
"xpad",
]);
await click(target, ".dropdown-menu li");
assert.verifySteps([`[("product_ids", "in", [37, 41])]`]);
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")), [
"xphone",
"xpad",
]);
assert.strictEqual(getAutocompletValue(target), "");
await selectOperator(target, "not in");
assert.strictEqual(getSelectedOperator(target), "is not in");
assert.strictEqual(getAutocompletValue(target), "");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")), [
"xphone",
"xpad",
]);
assert.verifySteps([`[("product_ids", "not in", [37, 41])]`]);
await click(target.querySelector(".o_tag .o_delete"));
assert.strictEqual(getSelectedOperator(target), "is not in");
assert.strictEqual(getAutocompletValue(target), "");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")), [
"xpad",
]);
assert.verifySteps([`[("product_ids", "not in", [41])]`]);
await selectOperator(target, "=");
assert.strictEqual(getSelectedOperator(target), "=");
assert.strictEqual(getAutocompletValue(target), "");
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")),
[]
); // to improve -> should be [xpad]
assert.verifySteps([`[("product_ids", "=", [])]`]);
await selectOperator(target, "!=");
assert.strictEqual(getSelectedOperator(target), "!=");
assert.strictEqual(getAutocompletValue(target), "");
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_ds_value_cell .o_tag")),
[]
);
assert.verifySteps([`[("product_ids", "!=", [])]`]);
});
QUnit.test("many2many field and operator ilike/not ilike (edit)", async (assert) => {
addProductIds();
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
await makeDomainSelector({
domain: `[("product_ids", "ilike", "abc")]`,
update(domain) {
assert.step(domain);
},
});
assert.strictEqual(getSelectedOperator(target), "contains");
assert.containsNone(target, ".o-autocomplete--input");
assert.containsOnce(target, ".o_ds_value_cell .o_input");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "abc");
assert.verifySteps([]);
await editInput(target, ".o_ds_value_cell .o_input", "def");
assert.strictEqual(getSelectedOperator(target), "contains");
assert.containsOnce(target, ".o_ds_value_cell .o_input");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "def");
assert.verifySteps([`[("product_ids", "ilike", "def")]`]);
await selectOperator(target, "not ilike");
assert.strictEqual(getSelectedOperator(target), "does not contain");
assert.containsOnce(target, ".o_ds_value_cell .o_input");
assert.strictEqual(target.querySelector(".o_ds_value_cell .o_input").value, "def");
assert.verifySteps([`[("product_ids", "not ilike", "def")]`]);
});
QUnit.test("many2many field and operator set/not set (edit)", async (assert) => {
addProductIds();
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
await makeDomainSelector({
domain: `[("product_ids", "=", False)]`,
update(domain) {
assert.step(domain);
},
});
assert.strictEqual(getSelectedOperator(target), "is not set");
assert.containsNone(target, ".o_ds_value_cell");
assert.verifySteps([]);
await selectOperator(target, "set");
assert.strictEqual(getSelectedOperator(target), "is set");
assert.containsNone(target, ".o_ds_value_cell");
assert.verifySteps([`[("product_ids", "!=", False)]`]);
});
});
@@ -1551,6 +1551,7 @@ QUnit.module("Search", (hooks) => {
serverData.models.foo.fields.country_id = {
string: "Country",
type: "many2one",
relation: "country",
};
await makeWithSearch({
serverData,
@@ -1825,7 +1826,7 @@ QUnit.module("Search", (hooks) => {
assert.deepEqual(getFacetTexts(target), [
"Bar = John",
"Bar is in ( David , Inaccessible/missing record ID: 5555 )",
"Bar is set",
"Bar != false",
"ID = 2",
]);
assert.deepEqual(getDomain(controlPanel), [