From bd0f2c6d799961b0a41222652eae7214c01dc5fe Mon Sep 17 00:00:00 2001 From: Pierre Rousseau Date: Tue, 3 Oct 2023 11:00:35 +0200 Subject: [PATCH] [IMP] web: introduce MultiRecordSelector and RecordSelector This commit introduces two new components, MultiRecordSelector and RecordSelector, which are used to select multiple records and a single record respectively. The MultiRecordSelector component is used to display the selected records with a tag-like style. It also allows to add new records by typing/selecting a valut in the input field. The RecordSelector component is used to display the selected record and allows to select a new record by typing/selecting a value in the input field. These components are created from the DomainSelectorAutocomplete and DomainSelectorSingleAutocomplete components, which are now an extension of the MultiRecordSelector and RecordSelector components respectively. This commit also add tests for these two components. Part of task-id 3433922 closes odoo/odoo#137650 Related: odoo/enterprise#48423 Signed-off-by: Aaron Bohy (aab) --- .../domain_selector_autocomplete.js | 227 ++---------------- .../domain_selector_autocomplete.xml | 47 ---- .../domain_selector/domain_selector_fields.js | 6 +- .../record_selectors/multi_record_selector.js | 62 +++++ .../multi_record_selector.xml | 21 ++ .../record_selectors/record_autocomplete.js | 140 +++++++++++ .../record_selectors/record_autocomplete.xml | 16 ++ .../core/record_selectors/record_selector.js | 57 +++++ .../core/record_selectors/record_selector.xml | 18 ++ .../multi_record_selector_tests.js | 166 +++++++++++++ .../record_selectors/record_selector_tests.js | 150 ++++++++++++ 11 files changed, 652 insertions(+), 258 deletions(-) delete mode 100644 addons/web/static/src/core/domain_selector/domain_selector_autocomplete.xml create mode 100644 addons/web/static/src/core/record_selectors/multi_record_selector.js create mode 100644 addons/web/static/src/core/record_selectors/multi_record_selector.xml create mode 100644 addons/web/static/src/core/record_selectors/record_autocomplete.js create mode 100644 addons/web/static/src/core/record_selectors/record_autocomplete.xml create mode 100644 addons/web/static/src/core/record_selectors/record_selector.js create mode 100644 addons/web/static/src/core/record_selectors/record_selector.xml create mode 100644 addons/web/static/tests/core/record_selectors/multi_record_selector_tests.js create mode 100644 addons/web/static/tests/core/record_selectors/record_selector_tests.js 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 index 3db747068b4..1f44ad75f1f 100644 --- a/addons/web/static/src/core/domain_selector/domain_selector_autocomplete.js +++ b/addons/web/static/src/core/domain_selector/domain_selector_autocomplete.js @@ -1,150 +1,14 @@ /** @odoo-module **/ -import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl"; -import { AutoComplete } from "@web/core/autocomplete/autocomplete"; +import { MultiRecordSelector } from "../record_selectors/multi_record_selector"; import { _t } from "@web/core/l10n/translation"; -import { Domain } from "@web/core/domain"; 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 { Expression } from "@web/core/domain_tree"; +import { RecordSelector } from "../record_selectors/record_selector"; -const SEARCH_LIMIT = 7; -const SEARCH_MORE_LIMIT = 320; +export const isId = (val) => Number.isInteger(val) && val >= 1; -const isId = (val) => Number.isInteger(val) && val >= 1; - -class AutoCompleteWithSources extends Component { - static props = { - resModel: String, - update: Function, - multiSelect: Boolean, - getIds: Function, - value: String, - domain: { type: Array, optional: true }, - context: { type: Object, optional: true }, - 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: _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: _t("Search: %s", fieldString), - dynamicFilters, - resModel, - noCreate: true, - multiSelect, - context: this.props.context || {}, - onSelected: (resId) => { - const resIds = Array.isArray(resId) ? resId : [resId]; - this.props.update([...resIds]); - }, - }); - } - - getDomain() { - const domainIds = Domain.not([["id", "in", this.getIds()]]); - if (this.props.domain) { - return Domain.and([this.props.domain, domainIds]).toList(); - } - return domainIds.toList(); - } - - onSelect({ value: resId, action }, params) { - if (action) { - return action(params); - } - this.props.update([resId]); - } - - search(name, limit) { - const domain = this.getDomain(); - return this.orm.call(this.props.resModel, "name_search", [], { - name, - args: domain, - limit, - context: this.props.context || {}, - }); - } - - onChange({ inputValue }) { - if (!inputValue.length) { - this.props.update([]); - } - } -} - -const getFormat = (val, displayNames) => { +export const getFormat = (val, displayNames) => { let text; let colorIndex; if (isId(val)) { @@ -163,105 +27,52 @@ const getFormat = (val, displayNames) => { return { text, colorIndex }; }; -export class DomainSelectorAutocomplete extends Component { +export class DomainSelectorAutocomplete extends MultiRecordSelector { static props = { - resModel: String, - update: Function, - value: true, - domain: { type: Array, optional: true }, - context: { type: Object, optional: true }, - fieldString: { type: String, optional: true }, + ...MultiRecordSelector.props, + resIds: true, //resIds could be an array of ids or an array of expressions }; - 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)); + return props.resIds.filter((val) => isId(val)); } getTags(props, displayNames) { - return props.value.map((val, index) => { + return props.resIds.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), + ...this.props.resIds.slice(0, index), + ...this.props.resIds.slice(index + 1), ]); }, }; }); } - - update(resIds) { - this.props.update([...this.props.value, ...resIds]); - } } -export class DomainSelectorSingleAutocomplete extends Component { +export class DomainSelectorSingleAutocomplete extends RecordSelector { static props = { - resModel: String, - update: Function, - domain: { type: Array, optional: true }, - context: { type: Object, optional: true }, - value: true, - fieldString: { type: String, optional: true }, + ...RecordSelector.props, + resId: 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) { + const { resId } = props; + if (resId === false) { return ""; } - const { text } = getFormat(value, displayNames); + const { text } = getFormat(resId, displayNames); return text; } getIds(props = this.props) { - if (isId(props.value)) { - return [props.value]; + if (isId(props.resId)) { + return [props.resId]; } 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 deleted file mode 100644 index b6e7453c79c..00000000000 --- a/addons/web/static/src/core/domain_selector/domain_selector_autocomplete.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - -
- - -
-
- - - - - -
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 57fd640935c..24e5658315f 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 @@ -319,7 +319,7 @@ const RELATIONAL_EDITOR_EQUALITY = makeEditor(DomainSelectorSingleAutocomplete, resModel: fieldDef.relation, fieldString: fieldDef.string, update, - value, + resId: value, }; }, isSupported: () => true, @@ -331,8 +331,8 @@ const RELATIONAL_EDITOR_IN = makeEditor(DomainSelectorAutocomplete, { return { resModel: fieldDef.relation, fieldString: fieldDef.string, - update: (value) => update([...new Set(value)]), - value: Array.isArray(value) ? [...new Set(value)] : value, + update: (resIds) => update([...new Set(resIds)]), + resIds: Array.isArray(value) ? [...new Set(value)] : value, }; }, isSupported: (value) => Array.isArray(value), diff --git a/addons/web/static/src/core/record_selectors/multi_record_selector.js b/addons/web/static/src/core/record_selectors/multi_record_selector.js new file mode 100644 index 00000000000..290c8ad4587 --- /dev/null +++ b/addons/web/static/src/core/record_selectors/multi_record_selector.js @@ -0,0 +1,62 @@ +/** @odoo-module **/ + +import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl"; +import { TagsList } from "@web/core/tags_list/tags_list"; +import { useService } from "@web/core/utils/hooks"; +import { RecordAutocomplete } from "./record_autocomplete"; +import { _t } from "@web/core/l10n/translation"; + +export class MultiRecordSelector extends Component { + static props = { + resIds: { type: Array, element: Number }, + resModel: String, + update: Function, + domain: { type: Array, optional: true }, + context: { type: Object, optional: true }, + fieldString: { type: String, optional: true }, + }; + static components = { RecordAutocomplete, TagsList }; + static template = "web.MultiRecordSelector"; + + 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.resIds; + } + + getTags(props, displayNames) { + return props.resIds.map((id, index) => { + const text = + typeof displayNames[id] === "string" + ? displayNames[id] + : _t("Inaccessible/missing record ID: %s", id); + return { + text, + onDelete: () => { + this.props.update([ + ...this.props.resIds.slice(0, index), + ...this.props.resIds.slice(index + 1), + ]); + }, + }; + }); + } + + update(resIds) { + this.props.update([...this.props.resIds, ...resIds]); + } +} diff --git a/addons/web/static/src/core/record_selectors/multi_record_selector.xml b/addons/web/static/src/core/record_selectors/multi_record_selector.xml new file mode 100644 index 00000000000..6bef7e439fb --- /dev/null +++ b/addons/web/static/src/core/record_selectors/multi_record_selector.xml @@ -0,0 +1,21 @@ + + + + +
+ + +
+
+ +
diff --git a/addons/web/static/src/core/record_selectors/record_autocomplete.js b/addons/web/static/src/core/record_selectors/record_autocomplete.js new file mode 100644 index 00000000000..6510645580f --- /dev/null +++ b/addons/web/static/src/core/record_selectors/record_autocomplete.js @@ -0,0 +1,140 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { AutoComplete } from "@web/core/autocomplete/autocomplete"; +import { _t } from "@web/core/l10n/translation"; +import { Domain } from "@web/core/domain"; +import { registry } from "@web/core/registry"; +import { useOwnedDialogs, useService } from "@web/core/utils/hooks"; + +const SEARCH_LIMIT = 7; +const SEARCH_MORE_LIMIT = 320; + +export class RecordAutocomplete extends Component { + static props = { + resModel: String, + update: Function, + multiSelect: Boolean, + getIds: Function, + value: String, + domain: { type: Array, optional: true }, + context: { type: Object, optional: true }, + className: { type: String, optional: true }, + fieldString: { type: String, optional: true }, + }; + static components = { AutoComplete }; + static template = "web.RecordAutocomplete"; + + 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: _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: _t("Search: %s", fieldString), + dynamicFilters, + resModel, + noCreate: true, + multiSelect, + context: this.props.context || {}, + onSelected: (resId) => { + const resIds = Array.isArray(resId) ? resId : [resId]; + this.props.update([...resIds]); + }, + }); + } + + getDomain() { + const domainIds = Domain.not([["id", "in", this.getIds()]]); + if (this.props.domain) { + return Domain.and([this.props.domain, domainIds]).toList(); + } + return domainIds.toList(); + } + + onSelect({ value: resId, action }, params) { + if (action) { + return action(params); + } + this.props.update([resId]); + } + + search(name, limit) { + const domain = this.getDomain(); + return this.orm.call(this.props.resModel, "name_search", [], { + name, + args: domain, + limit, + context: this.props.context || {}, + }); + } + + onChange({ inputValue }) { + if (!inputValue.length) { + this.props.update([]); + } + } +} diff --git a/addons/web/static/src/core/record_selectors/record_autocomplete.xml b/addons/web/static/src/core/record_selectors/record_autocomplete.xml new file mode 100644 index 00000000000..4830fea8777 --- /dev/null +++ b/addons/web/static/src/core/record_selectors/record_autocomplete.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/addons/web/static/src/core/record_selectors/record_selector.js b/addons/web/static/src/core/record_selectors/record_selector.js new file mode 100644 index 00000000000..9803c1517b0 --- /dev/null +++ b/addons/web/static/src/core/record_selectors/record_selector.js @@ -0,0 +1,57 @@ +/** @odoo-module **/ + +import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { RecordAutocomplete } from "./record_autocomplete"; +import { _t } from "@web/core/l10n/translation"; + +export class RecordSelector extends Component { + static props = { + resId: [Number, { value: false }], + resModel: String, + update: Function, + domain: { type: Array, optional: true }, + context: { type: Object, optional: true }, + fieldString: { type: String, optional: true }, + }; + static components = { RecordAutocomplete }; + static template = "web.RecordSelector"; + + 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 { resId } = props; + if (resId === false) { + return ""; + } + return typeof displayNames[resId] === "string" + ? displayNames[resId] + : _t("Inaccessible/missing record ID: %s", resId); + } + + getIds(props = this.props) { + if (props.resId) { + return [props.resId]; + } + return []; + } + + update(resIds) { + this.props.update(resIds[0] || false); + this.render(true); + } +} diff --git a/addons/web/static/src/core/record_selectors/record_selector.xml b/addons/web/static/src/core/record_selectors/record_selector.xml new file mode 100644 index 00000000000..1261eae8727 --- /dev/null +++ b/addons/web/static/src/core/record_selectors/record_selector.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/addons/web/static/tests/core/record_selectors/multi_record_selector_tests.js b/addons/web/static/tests/core/record_selectors/multi_record_selector_tests.js new file mode 100644 index 00000000000..a2718e154e2 --- /dev/null +++ b/addons/web/static/tests/core/record_selectors/multi_record_selector_tests.js @@ -0,0 +1,166 @@ +/** @odoo-module **/ + +import { MultiRecordSelector } from "@web/core/record_selectors/multi_record_selector"; +import { makeTestEnv } from "../../helpers/mock_env"; +import { getFixture, mount, click } from "../../helpers/utils"; +import { registry } from "@web/core/registry"; +import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; + +import { Component, useState, xml } from "@odoo/owl"; +import { nameService } from "@web/core/name_service"; +import { dialogService } from "@web/core/dialog/dialog_service"; + +QUnit.module("Web Components", (hooks) => { + QUnit.module("MultiRecordSelector"); + + let target; + const serverData = { + models: { + partner: { + fields: { + display_name: { string: "Display name", type: "char" }, + }, + records: [ + { id: 1, display_name: "Alice" }, + { id: 2, display_name: "Bob" }, + { id: 3, display_name: "Charlie" }, + ], + }, + }, + }; + + async function makeMultiRecordSelector(props, { mockRPC } = {}) { + class Parent extends Component { + setup() { + this.state = useState({ resIds: props.resIds }); + } + + get recordProps() { + return { + ...props, + resIds: this.state.resIds, + update: (resIds) => this._update(resIds), + }; + } + + _update(resIds) { + this.state.resIds = resIds; + } + } + Parent.components = { MultiRecordSelector }; + Parent.template = xml` + `; + + const env = await makeTestEnv({ serverData, mockRPC }); + await mount(Parent, target, { env }); + } + + hooks.beforeEach(async () => { + target = getFixture(); + registry.category("services").add("hotkey", hotkeyService); + registry.category("services").add("dialog", dialogService); + registry.category("services").add("name", nameService); + }); + + QUnit.test("Can be renderer with no values", async (assert) => { + await makeMultiRecordSelector({ + resModel: "partner", + resIds: [], + }); + const input = target.querySelector(".o_multi_record_selector input"); + assert.strictEqual(input.value, "", "The input should be empty"); + assert.hasClass(input, "o_input"); + }); + + QUnit.test("Can be renderer with a value", async (assert) => { + await makeMultiRecordSelector({ + resModel: "partner", + resIds: [1], + }); + const input = target.querySelector(".o_multi_record_selector input"); + assert.strictEqual(input.value, ""); + assert.strictEqual(target.querySelectorAll(".o_tag").length, 1); + assert.strictEqual(target.querySelector(".o_tag").textContent, "Alice"); + }); + + QUnit.test("Can be renderer with multiple values", async (assert) => { + await makeMultiRecordSelector({ + resModel: "partner", + resIds: [1, 2], + }); + const input = target.querySelector(".o_multi_record_selector input"); + assert.strictEqual(input.value, ""); + assert.strictEqual(target.querySelectorAll(".o_tag").length, 2); + assert.deepEqual( + [...target.querySelectorAll(".o_tag")].map((el) => el.textContent), + ["Alice", "Bob"] + ); + }); + + QUnit.test("Can be updated from autocomplete", async (assert) => { + await makeMultiRecordSelector({ + resModel: "partner", + resIds: [], + }); + const input = target.querySelector(".o_multi_record_selector input"); + assert.containsNone(target, ".o_tag"); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + await click(input); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + const secondItem = target.querySelectorAll("li.o-autocomplete--dropdown-item")[1]; + await click(secondItem); + assert.containsOnce(target, ".o_tag"); + assert.strictEqual(target.querySelector(".o_tag").textContent, "Bob"); + }); + + QUnit.test("Display name is correctly fetched", async (assert) => { + await makeMultiRecordSelector( + { + resModel: "partner", + resIds: [1], + }, + { + mockRPC: (route, args) => { + if (args.method === "web_search_read") { + assert.step("web_search_read"); + assert.strictEqual(args.model, "partner"); + assert.deepEqual(args.kwargs.domain, [["id", "in", [1]]]); + } + }, + } + ); + assert.strictEqual(target.querySelectorAll(".o_tag").length, 1); + assert.strictEqual(target.querySelector(".o_tag").textContent, "Alice"); + assert.verifySteps(["web_search_read"]); + }); + + QUnit.test("Can give domain and context props for the name search", async (assert) => { + await makeMultiRecordSelector( + { + resModel: "partner", + resIds: [1], + domain: [["display_name", "=", "Bob"]], + context: { blip: "blop " }, + }, + { + mockRPC: (route, args) => { + if (args.method === "name_search") { + assert.step("name_search"); + assert.strictEqual(args.model, "partner"); + assert.deepEqual(args.kwargs.args, [ + "&", + ["display_name", "=", "Bob"], + "!", + ["id", "in", [1]], + ]); + assert.strictEqual(args.kwargs.context.blip, "blop "); + } + }, + } + ); + const input = target.querySelector(".o_multi_record_selector input"); + assert.verifySteps([]); + await click(input); + assert.verifySteps(["name_search"]); + }); +}); diff --git a/addons/web/static/tests/core/record_selectors/record_selector_tests.js b/addons/web/static/tests/core/record_selectors/record_selector_tests.js new file mode 100644 index 00000000000..c7f0d9cbd3c --- /dev/null +++ b/addons/web/static/tests/core/record_selectors/record_selector_tests.js @@ -0,0 +1,150 @@ +/** @odoo-module **/ + +import { RecordSelector } from "@web/core/record_selectors/record_selector"; +import { makeTestEnv } from "../../helpers/mock_env"; +import { getFixture, mount, click } from "../../helpers/utils"; +import { registry } from "@web/core/registry"; +import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; + +import { Component, useState, xml } from "@odoo/owl"; +import { nameService } from "@web/core/name_service"; +import { dialogService } from "@web/core/dialog/dialog_service"; + +QUnit.module("Web Components", (hooks) => { + QUnit.module("RecordSelector"); + + let target; + const serverData = { + models: { + partner: { + fields: { + display_name: { string: "Display name", type: "char" }, + }, + records: [ + { id: 1, display_name: "Alice" }, + { id: 2, display_name: "Bob" }, + { id: 3, display_name: "Charlie" }, + ], + }, + }, + }; + + async function makeRecordSelector(props, { mockRPC } = {}) { + class Parent extends Component { + setup() { + this.state = useState({ resId: props.resId }); + } + + get recordProps() { + return { + ...props, + resId: this.state.resId, + update: (resId) => this._update(resId), + }; + } + + _update(resId) { + this.state.resId = resId; + } + } + Parent.components = { RecordSelector }; + Parent.template = xml` + `; + + const env = await makeTestEnv({ serverData, mockRPC }); + await mount(Parent, target, { env }); + } + + hooks.beforeEach(async () => { + target = getFixture(); + registry.category("services").add("hotkey", hotkeyService); + registry.category("services").add("dialog", dialogService); + registry.category("services").add("name", nameService); + }); + + QUnit.test("Can be renderer with no values", async (assert) => { + await makeRecordSelector({ + resModel: "partner", + resId: false, + }); + const input = target.querySelector(".o_record_selector input"); + assert.strictEqual(input.value, "", "The input should be empty"); + assert.hasClass(input, "o_input"); + }); + + QUnit.test("Can be renderer with a value", async (assert) => { + await makeRecordSelector({ + resModel: "partner", + resId: 1, + }); + const input = target.querySelector(".o_record_selector input"); + assert.strictEqual(input.value, "Alice"); + }); + + QUnit.test("Can be updated from autocomplete", async (assert) => { + await makeRecordSelector({ + resModel: "partner", + resId: 1, + }); + const input = target.querySelector(".o_record_selector input"); + assert.strictEqual(input.value, "Alice"); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + await click(input); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + const secondItem = target.querySelectorAll("li.o-autocomplete--dropdown-item")[1]; + await click(secondItem); + assert.strictEqual(input.value, "Bob"); + }); + + QUnit.test("Display name is correctly fetched", async (assert) => { + await makeRecordSelector( + { + resModel: "partner", + resId: 1, + }, + { + mockRPC: (route, args) => { + if (args.method === "web_search_read") { + assert.step("web_search_read"); + assert.strictEqual(args.model, "partner"); + assert.deepEqual(args.kwargs.domain, [["id", "in", [1]]]); + } + }, + } + ); + const input = target.querySelector(".o_record_selector input"); + assert.strictEqual(input.value, "Alice"); + assert.verifySteps(["web_search_read"]); + }); + + QUnit.test("Can give domain and context props for the name search", async (assert) => { + await makeRecordSelector( + { + resModel: "partner", + resId: 1, + domain: [["display_name", "=", "Bob"]], + context: { blip: "blop " }, + }, + { + mockRPC: (route, args) => { + if (args.method === "name_search") { + assert.step("name_search"); + assert.strictEqual(args.model, "partner"); + assert.deepEqual(args.kwargs.args, [ + "&", + ["display_name", "=", "Bob"], + "!", + ["id", "in", []], + ]); + assert.strictEqual(args.kwargs.context.blip, "blop "); + } + }, + } + ); + const input = target.querySelector(".o_record_selector input"); + assert.strictEqual(input.value, "Alice"); + assert.verifySteps([]); + await click(input); + assert.verifySteps(["name_search"]); + }); +});