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"]);
+ });
+});