diff --git a/addons/web/static/src/core/domain_selector/domain_selector.js b/addons/web/static/src/core/domain_selector/domain_selector.js index aed7e67e3fe..01fec1a50a2 100644 --- a/addons/web/static/src/core/domain_selector/domain_selector.js +++ b/addons/web/static/src/core/domain_selector/domain_selector.js @@ -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); } diff --git a/addons/web/static/src/core/domain_selector/domain_selector.xml b/addons/web/static/src/core/domain_selector/domain_selector.xml index 8616a9204e5..34a1852f0c4 100644 --- a/addons/web/static/src/core/domain_selector/domain_selector.xml +++ b/addons/web/static/src/core/domain_selector/domain_selector.xml @@ -168,9 +168,9 @@ - +
- +
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_autocomplete.js b/addons/web/static/src/core/domain_selector/domain_selector_autocomplete.js new file mode 100644 index 00000000000..60c40ec0d9a --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_autocomplete.js @@ -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); + } +} diff --git a/addons/web/static/src/core/domain_selector/domain_selector_autocomplete.xml b/addons/web/static/src/core/domain_selector/domain_selector_autocomplete.xml new file mode 100644 index 00000000000..01c942c3f3f --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_autocomplete.xml @@ -0,0 +1,43 @@ + + + + + + + + +
+ + +
+
+ + + + + +
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_fields.js b/addons/web/static/src/core/domain_selector/domain_selector_fields.js index ed25f989ca5..6022584e3ee 100644 --- a/addons/web/static/src/core/domain_selector/domain_selector_fields.js +++ b/addons/web/static/src/core/domain_selector/domain_selector_fields.js @@ -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]; } diff --git a/addons/web/static/src/core/domain_selector/domain_selector_fields.xml b/addons/web/static/src/core/domain_selector/domain_selector_fields.xml index c5d40fe772f..c3e99edce5f 100644 --- a/addons/web/static/src/core/domain_selector/domain_selector_fields.xml +++ b/addons/web/static/src/core/domain_selector/domain_selector_fields.xml @@ -2,18 +2,18 @@ - -
- - -
- - - @@ -52,9 +52,9 @@
- + - +
diff --git a/addons/web/static/src/core/domain_selector/utils.js b/addons/web/static/src/core/domain_selector/utils.js index 447c8a605ce..977919ef717 100644 --- a/addons/web/static/src/core/domain_selector/utils.js +++ b/addons/web/static/src/core/domain_selector/utils.js @@ -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, }); } diff --git a/addons/web/static/src/views/view_dialogs/select_create_dialog.js b/addons/web/static/src/views/view_dialogs/select_create_dialog.js index 54c7c73bcc5..011750d2575 100644 --- a/addons/web/static/src/views/view_dialogs/select_create_dialog.js +++ b/addons/web/static/src/views/view_dialogs/select_create_dialog.js @@ -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); diff --git a/addons/web/static/tests/core/domain_selector_tests.js b/addons/web/static/tests/core/domain_selector_tests.js index 962ecb16484..5bf4adb7139 100644 --- a/addons/web/static/tests/core/domain_selector_tests.js +++ b/addons/web/static/tests/core/domain_selector_tests.js @@ -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)]`]); + }); }); diff --git a/addons/web/static/tests/search/search_bar_menu_tests.js b/addons/web/static/tests/search/search_bar_menu_tests.js index 0edf5483f36..f5271dd25d3 100644 --- a/addons/web/static/tests/search/search_bar_menu_tests.js +++ b/addons/web/static/tests/search/search_bar_menu_tests.js @@ -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), [