diff --git a/addons/web/static/src/search/search_arch_parser.js b/addons/web/static/src/search/search_arch_parser.js
index e9a499d0cad..fbb270d982a 100644
--- a/addons/web/static/src/search/search_arch_parser.js
+++ b/addons/web/static/src/search/search_arch_parser.js
@@ -127,15 +127,16 @@ export class SearchArchParser extends XMLParser {
}
if (node.hasAttribute("name")) {
const name = node.getAttribute("name");
+ const fieldType = this.fields[name].type;
preField.fieldName = name;
- preField.fieldType = this.fields[name].type;
- if (name in this.searchDefaults) {
+ preField.fieldType = fieldType;
+ if (fieldType !== "properties" && name in this.searchDefaults) {
preField.isDefault = true;
let value = this.searchDefaults[name];
value = Array.isArray(value) ? value[0] : value;
let operator = preField.operator;
if (!operator) {
- let type = preField.fieldType;
+ let type = fieldType;
if (node.hasAttribute("widget")) {
type = node.getAttribute("widget");
}
@@ -149,8 +150,7 @@ export class SearchArchParser extends XMLParser {
}
}
preField.defaultRank = -10;
- const { fieldType, fieldName } = preField;
- const { selection, context, relation } = this.fields[fieldName];
+ const { selection, context, relation } = this.fields[name];
preField.defaultAutocompleteValue = { label: `${value}`, operator, value };
if (fieldType === "selection") {
const option = selection.find((sel) => sel[0] === value);
@@ -160,9 +160,11 @@ export class SearchArchParser extends XMLParser {
preField.defaultAutocompleteValue.label = option[1];
} else if (fieldType === "many2one") {
this.labels.push((orm) => {
- return orm.call(relation, "name_get", [value], { context }).then((results) => {
- preField.defaultAutocompleteValue.label = results[0][1];
- });
+ return orm
+ .call(relation, "name_get", [value], { context })
+ .then((results) => {
+ preField.defaultAutocompleteValue.label = results[0][1];
+ });
});
}
}
diff --git a/addons/web/static/src/search/search_bar/search_bar.js b/addons/web/static/src/search/search_bar/search_bar.js
index bd5e166d52e..d1e5e86c544 100644
--- a/addons/web/static/src/search/search_bar/search_bar.js
+++ b/addons/web/static/src/search/search_bar/search_bar.js
@@ -10,14 +10,15 @@ import { fuzzyTest } from "@web/core/utils/search";
import { Component, useExternalListener, useRef, useState } from "@odoo/owl";
const parsers = registry.category("parsers");
-const CHAR_FIELDS = ["char", "html", "many2many", "many2one", "one2many", "text"];
+const CHAR_FIELDS = ["char", "html", "many2many", "many2one", "one2many", "text", "properties"];
+const FOLDABLE_TYPES = ["properties", "many2one", "many2many"];
let nextItemId = 1;
export class SearchBar extends Component {
setup() {
this.fields = this.env.searchModel.searchViewFields;
- this.searchItems = this.env.searchModel.getSearchItems((f) => f.type === "field");
+ this.searchItemsFields = this.env.searchModel.getSearchItems((f) => f.type === "field");
this.root = useRef("root");
// core state
@@ -49,9 +50,13 @@ export class SearchBar extends Component {
useExternalListener(window, "keydown", this.onWindowKeydown);
}
- //---------------------------------------------------------------------
- // Private
- //---------------------------------------------------------------------
+ /**
+ * @param {number} id
+ * @param {Object}
+ */
+ getSearchItem(id) {
+ return this.env.searchModel.searchItems[id];
+ }
/**
* @param {Object} [options={}]
@@ -70,8 +75,11 @@ export class SearchBar extends Component {
const tasks = [];
for (const id of expanded) {
- if (!subItems[id]) {
- tasks.push({ id, prom: this.computeSubItems(id, query) });
+ const searchItem = this.getSearchItem(id);
+ if (searchItem.type === "field" && searchItem.fieldType === "properties") {
+ tasks.push({ id, prom: this.getSearchItemsProperties(searchItem) });
+ } else if (!subItems[id]) {
+ tasks.push({ id, prom: this.computeSubItems(searchItem, query) });
}
}
@@ -98,88 +106,144 @@ export class SearchBar extends Component {
return;
}
- for (const searchItem of this.searchItems) {
- const field = this.fields[searchItem.fieldName];
- const type = field.type === "reference" ? "char" : field.type;
- /** @todo do something with respect to localization (rtl) */
- const preposition = this.env._t(["date", "datetime"].includes(type) ? "at" : "for");
-
- if (["selection", "boolean"].includes(type)) {
- const options = field.selection || [
- [true, this.env._t("Yes")],
- [false, this.env._t("No")],
- ];
- for (const [value, label] of options) {
- if (fuzzyTest(trimmedQuery.toLowerCase(), label.toLowerCase())) {
- this.items.push({
- id: nextItemId++,
- searchItemDescription: searchItem.description,
- preposition,
- searchItemId: searchItem.id,
- label,
- /** @todo check if searchItem.operator is fine (here and elsewhere) */
- operator: searchItem.operator || "=",
- value,
- });
- }
- }
- continue;
- }
-
- const parser = parsers.contains(type) ? parsers.get(type) : (str) => str;
- let value;
- try {
- switch (type) {
- case "date": {
- value = serializeDate(parser(trimmedQuery));
- break;
- }
- case "datetime": {
- value = serializeDateTime(parser(trimmedQuery));
- break;
- }
- case "many2one": {
- value = trimmedQuery;
- break;
- }
- default: {
- value = parser(trimmedQuery);
- }
- }
- } catch {
- continue;
- }
-
- const item = {
- id: nextItemId++,
- searchItemDescription: searchItem.description,
- preposition,
- searchItemId: searchItem.id,
- label: this.state.query,
- operator: searchItem.operator || (CHAR_FIELDS.includes(type) ? "ilike" : "="),
- value,
- };
-
- if (type === "many2one") {
- item.isParent = true;
- item.isExpanded = this.state.expanded.includes(item.searchItemId);
- }
-
- this.items.push(item);
-
- if (item.isExpanded) {
- this.items.push(...this.subItems[searchItem.id]);
- }
+ for (const searchItem of this.searchItemsFields) {
+ this.items.push(...this.getItems(searchItem, trimmedQuery));
}
}
/**
- * @param {number} searchItemId
+ * @param {Object} searchItem
+ * @param {string} trimmedQuery
+ * @returns {Object[]}
+ */
+ getItems(searchItem, trimmedQuery) {
+ const items = [];
+
+ const isFieldProperty = searchItem.type === "field_property";
+
+ const { type } = isFieldProperty
+ ? searchItem.propertyFieldDefinition
+ : this.fields[searchItem.fieldName];
+ const fieldType = type === "reference" ? "char" : type;
+
+ /** @todo do something with respect to localization (rtl) */
+ let preposition = this.env._t(["date", "datetime"].includes(fieldType) ? "at" : "for");
+
+ if ((isFieldProperty && FOLDABLE_TYPES.includes(fieldType)) || fieldType === "properties") {
+ // Do not chose preposition for foldable properties
+ // or the properties item itself
+ preposition = null;
+ }
+
+ if (["selection", "boolean", "tags"].includes(fieldType)) {
+ const booleanOptions = [
+ [true, this.env._t("Yes")],
+ [false, this.env._t("No")],
+ ];
+ let options;
+ if (isFieldProperty) {
+ const { selection, tags } = searchItem.propertyFieldDefinition || {};
+ options = selection || tags || booleanOptions;
+ } else {
+ options = this.fields[searchItem.fieldName].selection || booleanOptions;
+ }
+ for (const [value, label] of options) {
+ if (fuzzyTest(trimmedQuery.toLowerCase(), label.toLowerCase())) {
+ items.push({
+ id: nextItemId++,
+ searchItemDescription: searchItem.description,
+ preposition,
+ searchItemId: searchItem.id,
+ label,
+ /** @todo check if searchItem.operator is fine (here and elsewhere) */
+ operator: searchItem.operator || "=",
+ value,
+ isFieldProperty,
+ });
+ }
+ }
+ return items;
+ }
+
+ const parser = parsers.contains(fieldType) ? parsers.get(fieldType) : (str) => str;
+ let value;
+ try {
+ switch (fieldType) {
+ case "date": {
+ value = serializeDate(parser(trimmedQuery));
+ break;
+ }
+ case "datetime": {
+ value = serializeDateTime(parser(trimmedQuery));
+ break;
+ }
+ case "many2one": {
+ value = trimmedQuery;
+ break;
+ }
+ default: {
+ value = parser(trimmedQuery);
+ }
+ }
+ } catch {
+ return [];
+ }
+
+ const item = {
+ id: nextItemId++,
+ searchItemDescription: searchItem.description,
+ preposition,
+ searchItemId: searchItem.id,
+ label: this.state.query,
+ operator: searchItem.operator || (CHAR_FIELDS.includes(fieldType) ? "ilike" : "="),
+ value,
+ isFieldProperty,
+ };
+
+ if (isFieldProperty) {
+ item.isParent = FOLDABLE_TYPES.includes(fieldType);
+ item.unselectable = FOLDABLE_TYPES.includes(fieldType);
+ item.propertyItemId = searchItem.propertyItemId;
+ } else if (fieldType === "properties") {
+ item.isParent = true;
+ item.unselectable = true;
+ } else if (fieldType === "many2one") {
+ item.isParent = true;
+ }
+
+ if (item.isParent) {
+ item.isExpanded = this.state.expanded.includes(item.searchItemId);
+ }
+
+ items.push(item);
+
+ if (item.isExpanded) {
+ if (searchItem.type === "field" && searchItem.fieldType === "properties") {
+ for (const subItem of this.subItems[searchItem.id]) {
+ items.push(...this.getItems(subItem, trimmedQuery));
+ }
+ } else {
+ items.push(...this.subItems[searchItem.id]);
+ }
+ }
+
+ return items;
+ }
+
+ /**
+ * @param {Object} searchItem
+ * @returns {Object[]}
+ */
+ getSearchItemsProperties(searchItem) {
+ return this.env.searchModel.getSearchItemsProperties(searchItem);
+ }
+
+ /**
+ * @param {Object} searchItem
* @param {string} query
* @returns {Object[]}
*/
- async computeSubItems(searchItemId, query) {
- const searchItem = this.searchItems.find((i) => i.id === searchItemId);
+ async computeSubItems(searchItem, query) {
const field = this.fields[searchItem.fieldName];
let domain = [];
if (searchItem.domain) {
@@ -189,7 +253,12 @@ export class SearchBar extends Component {
// Pass
}
}
- const options = await this.orm.call(field.relation, "name_search", [], {
+ const relation =
+ searchItem.type === "field_property"
+ ? searchItem.propertyFieldDefinition.comodel
+ : field.relation;
+
+ const options = await this.orm.call(relation, "name_search", [], {
args: domain,
context: { ...this.env.searchModel.globalContext, ...field.context },
limit: 8,
@@ -202,7 +271,7 @@ export class SearchBar extends Component {
subItems.push({
id: nextItemId++,
isChild: true,
- searchItemId,
+ searchItemId: searchItem.id,
value,
label,
operator,
@@ -212,7 +281,7 @@ export class SearchBar extends Component {
subItems.push({
id: nextItemId++,
isChild: true,
- searchItemId,
+ searchItemId: searchItem.id,
label: this.env._t("(no result)"),
unselectable: true,
});
@@ -251,6 +320,15 @@ export class SearchBar extends Component {
* @param {Object} item
*/
selectItem(item) {
+ const searchItem = this.getSearchItem(item.searchItemId);
+ if (
+ (searchItem.type === "field" && searchItem.fieldType === "properties") ||
+ (searchItem.type === "field_property" && item.unselectable)
+ ) {
+ this.toggleItem(item, !item.isExpanded);
+ return;
+ }
+
if (!item.unselectable) {
const { searchItemId, label, operator, value } = item;
this.env.searchModel.addAutoCompletionValues(searchItemId, { label, operator, value });
@@ -373,6 +451,11 @@ export class SearchBar extends Component {
focusedIndex = this.items.findIndex(
(item) => item.isParent && item.searchItemId === focusedItem.searchItemId
);
+ } else if (focusedItem && focusedItem.isFieldProperty) {
+ ev.preventDefault();
+ focusedIndex = this.items.findIndex(
+ (item) => item.isParent && item.searchItemId === focusedItem.propertyItemId
+ );
} else if (ev.target.selectionStart === 0) {
// focus rightmost facet if any.
this.focusFacet();
diff --git a/addons/web/static/src/search/search_bar/search_bar.xml b/addons/web/static/src/search/search_bar/search_bar.xml
index 974603a1dcf..ca7b6f1a7d8 100644
--- a/addons/web/static/src/search/search_bar/search_bar.xml
+++ b/addons/web/static/src/search/search_bar/search_bar.xml
@@ -55,20 +55,18 @@
t-on-mousemove="() => this.onItemMousemove(item_index)"
>
-
-
+
-
- Search :
-
+ Search :
diff --git a/addons/web/static/src/search/search_model.js b/addons/web/static/src/search/search_model.js
index 27bcaf15af5..83ebdc51d34 100644
--- a/addons/web/static/src/search/search_model.js
+++ b/addons/web/static/src/search/search_model.js
@@ -121,15 +121,8 @@ function arraytoMap(array) {
* @param {Object} target
*/
function execute(op, source, target) {
- const {
- query,
- nextId,
- nextGroupId,
- nextGroupNumber,
- searchItems,
- searchPanelInfo,
- sections,
- } = source;
+ const { query, nextId, nextGroupId, nextGroupNumber, searchItems, searchPanelInfo, sections } =
+ source;
target.nextGroupId = nextGroupId;
target.nextGroupNumber = nextGroupNumber;
@@ -518,7 +511,7 @@ export class SearchModel extends EventBus {
*/
addAutoCompletionValues(searchItemId, autocompleteValue) {
const searchItem = this.searchItems[searchItemId];
- if (searchItem.type !== "field") {
+ if (!["field", "field_property"].includes(searchItem.type)) {
return;
}
const { label, value, operator } = autocompleteValue;
@@ -715,9 +708,11 @@ export class SearchModel extends EventBus {
return searchItem.comparison;
}
const { dateFilterId, comparisonOptionId } = searchItem;
- const { fieldName, fieldType, description: dateFilterDescription } = this.searchItems[
- dateFilterId
- ];
+ const {
+ fieldName,
+ fieldType,
+ description: dateFilterDescription,
+ } = this.searchItems[dateFilterId];
const selectedGeneratorIds = this._getSelectedGeneratorIds(dateFilterId);
// compute range and range description
const { domain: range, description: rangeDescription } = constructDateDomain(
@@ -727,16 +722,14 @@ export class SearchModel extends EventBus {
selectedGeneratorIds
);
// compute comparisonRange and comparisonRange description
- const {
- domain: comparisonRange,
- description: comparisonRangeDescription,
- } = constructDateDomain(
- this.referenceMoment,
- fieldName,
- fieldType,
- selectedGeneratorIds,
- comparisonOptionId
- );
+ const { domain: comparisonRange, description: comparisonRangeDescription } =
+ constructDateDomain(
+ this.referenceMoment,
+ fieldName,
+ fieldType,
+ selectedGeneratorIds,
+ comparisonOptionId
+ );
return {
comparisonId: comparisonOptionId,
fieldName,
@@ -854,6 +847,7 @@ export class SearchModel extends EventBus {
switch (searchItem.type) {
case "dateFilter":
case "dateGroupBy":
+ case "field_property":
case "field": {
return;
}
@@ -941,10 +935,104 @@ export class SearchModel extends EventBus {
this._notify();
}
+ /**
+ * Generate the searchItems corresponding to the properties.
+ * @param {Object} searchItem
+ * @returns {Object[]}
+ */
+ async getSearchItemsProperties(searchItem) {
+ if (searchItem.type !== "field" || searchItem.fieldType !== "properties") {
+ return [];
+ }
+ const field = this.searchViewFields[searchItem.fieldName];
+ const definitionRecord = field.definition_record;
+ const definitionRecordModel = this.searchViewFields[definitionRecord].relation;
+ const definitionRecordField = field.definition_record_field;
+
+ const result = await this._fetchPropertiesDefinition(
+ definitionRecordModel,
+ definitionRecordField
+ );
+
+ const searchItemIds = new Set();
+ const existingFieldProperties = {};
+ for (const item of Object.values(this.searchItems)) {
+ if (item.type === "field_property" && item.propertyItemId === searchItem.id) {
+ existingFieldProperties[item.propertyFieldDefinition.name] = item;
+ }
+ }
+
+ for (const { definitionRecordId, definitionRecordName, definitions } of result) {
+ for (const definition of definitions) {
+ const existingSearchItem = existingFieldProperties[definition.name];
+ if (existingSearchItem) {
+ // already in the list, can happen if we unfold the properties field
+ // open a form view, edit the property and then go back to the search view
+ // the label of the property might have been changed
+ existingSearchItem.description = `${definition.string} (${definitionRecordName})`;
+ searchItemIds.add(existingSearchItem.id);
+ continue;
+ }
+ const id = this.nextId++;
+ const newSearchItem = {
+ id,
+ type: "field_property",
+ fieldName: searchItem.fieldName,
+ propertyDomain: [definitionRecord, "=", definitionRecordId],
+ propertyFieldDefinition: definition,
+ propertyItemId: searchItem.id,
+ description: `${definition.string} (${definitionRecordName})`,
+ groupId: this.nextGroupId++,
+ };
+ if (["many2many", "tags"].includes(definition.type)) {
+ newSearchItem.operator = "in";
+ }
+ this.searchItems[id] = newSearchItem;
+ searchItemIds.add(id);
+ }
+ }
+
+ return this.getSearchItems((searchItem) => searchItemIds.has(searchItem.id));
+ }
+
//--------------------------------------------------------------------------
// Private methods
//--------------------------------------------------------------------------
+ /**
+ * Fetch the properties definitions.
+ *
+ * @param {string} definitionRecordModel
+ * @param {string} definitionRecordField
+ * @return {Object[]} A list of objects of the form
+ * {
+ * definitionRecordId:
+ * definitionRecordName:
+ * definitions:
+ * }
+ */
+ async _fetchPropertiesDefinition(definitionRecordModel, definitionRecordField) {
+ const domain = [];
+ if (this.context.active_id) {
+ // assume the active id is the definition record
+ // and show only its properties
+ domain.push(["id", "=", this.context.active_id]);
+ }
+
+ const result = await this.orm.webSearchRead(definitionRecordModel, domain, [
+ "display_name",
+ definitionRecordField,
+ ]);
+
+ return result.records.map((values) => {
+ return {
+ definitionRecordId: values.id,
+ definitionRecordName: values.display_name,
+ definitions: values[definitionRecordField],
+ };
+ });
+ }
+
/**
* Activate the default favorite (if any) or all default filters.
*/
@@ -1170,6 +1258,9 @@ export class SearchModel extends EventBus {
* for some reason.
*/
_enrichItem(searchItem) {
+ if (searchItem.type === "field" && searchItem.fieldType === "properties") {
+ return { ...searchItem };
+ }
const queryElements = this.query.filter(
(queryElem) => queryElem.searchItemId === searchItem.id
);
@@ -1206,6 +1297,7 @@ export class SearchModel extends EventBus {
);
break;
case "field":
+ case "field_property":
enrichSearchItem.autocompleteValues = queryElements.map(
(queryElem) => queryElem.autocompleteValue
);
@@ -1465,6 +1557,7 @@ export class SearchModel extends EventBus {
for (const activeItem of group.activeItems) {
const searchItem = this.searchItems[activeItem.searchItemId];
switch (searchItem.type) {
+ case "field_property":
case "field": {
type = "field";
title = searchItem.description;
@@ -1541,8 +1634,13 @@ export class SearchModel extends EventBus {
self: label.trim(),
raw_value: value,
});
- } else {
+ } else if (field.type === "field") {
domain = [[field.fieldName, operator, value]];
+ } else if (field.type === "field_property") {
+ domain = [
+ field.propertyDomain,
+ [`${field.fieldName}.${field.propertyFieldDefinition.name}`, operator, value],
+ ];
}
return new Domain(domain);
});
@@ -1878,6 +1976,7 @@ export class SearchModel extends EventBus {
const { searchItemId } = activeItem;
const searchItem = this.searchItems[searchItemId];
switch (searchItem.type) {
+ case "field_property":
case "field": {
return this._getFieldDomain(searchItem, activeItem.autocompletValues);
}
diff --git a/addons/web/static/tests/search/search_bar_tests.js b/addons/web/static/tests/search/search_bar_tests.js
index cc3ac129d8c..a4f7281112d 100644
--- a/addons/web/static/tests/search/search_bar_tests.js
+++ b/addons/web/static/tests/search/search_bar_tests.js
@@ -39,6 +39,12 @@ QUnit.module("Search", (hooks) => {
foo: { string: "Foo", type: "char" },
bool: { string: "Bool", type: "boolean" },
company: { string: "Company", type: "many2one", relation: "partner" },
+ properties: {
+ string: "Properties",
+ type: "properties",
+ definition_record: "bar",
+ definition_record_field: "child_properties",
+ },
},
records: [
{
@@ -1054,4 +1060,357 @@ QUnit.module("Search", (hooks) => {
await editSearch(target, "F");
await triggerEvent(target, ".o_searchview input", "keydown", { key: "ArrowRight" });
});
+
+ QUnit.test("search a property", async function (assert) {
+ assert.expect(57);
+
+ async function mockRPC(_, { method, model, kwargs }) {
+ if (
+ method === "web_search_read" &&
+ model === "partner" &&
+ kwargs.fields[0] === "display_name" &&
+ kwargs.fields[1] === "child_properties"
+ ) {
+ const definition1 = [
+ {
+ type: "many2one",
+ string: "My Partner",
+ name: "my_partner",
+ comodel: "partner",
+ },
+ {
+ type: "many2many",
+ string: "My Partners",
+ name: "my_partners",
+ comodel: "partner",
+ },
+ {
+ type: "selection",
+ string: "My Selection",
+ name: "my_selection",
+ selection: [
+ ["a", "A"],
+ ["b", "B"],
+ ["c", "C"],
+ ["aa", "AA"],
+ ],
+ },
+ {
+ type: "tags",
+ string: "My Tags",
+ name: "my_tags",
+ tags: [
+ ["a", "A", 1],
+ ["b", "B", 5],
+ ["c", "C", 3],
+ ["aa", "AA", 2],
+ ],
+ },
+ ];
+
+ const definition2 = [
+ {
+ type: "char",
+ string: "My Text",
+ name: "my_text",
+ },
+ ];
+
+ return {
+ records: [
+ { id: 1, display_name: "Bar 1", child_properties: definition1 },
+ { id: 2, display_name: "Bar 2", child_properties: definition2 },
+ ],
+ };
+ } else if (method === "name_search" && model === "partner" && kwargs.name === "Bo") {
+ return [
+ [5, "Bob"],
+ [6, "Bobby"],
+ ];
+ } else if (method === "name_search" && model === "partner" && kwargs.name === "Ali") {
+ return [
+ [9, "Alice"],
+ [10, "Alicia"],
+ ];
+ }
+ }
+
+ const controlPanel = await makeWithSearch({
+ serverData,
+ resModel: "partner",
+ Component: ControlPanel,
+ searchMenuTypes: [],
+ searchViewId: false,
+ searchViewArch: `
+
+
+
+ `,
+ mockRPC,
+ });
+
+ // expand the properties field
+ await editSearch(target, "a");
+
+ await click(target.querySelector(".o_expand"));
+
+ let items = target.querySelectorAll(".o_searchview_input_container li");
+ assert.strictEqual(items.length, 8);
+ assert.strictEqual(items[0].innerText, "Search Properties");
+ assert.strictEqual(items[1].innerText, "My Partner (Bar 1)");
+ assert.strictEqual(items[3].innerText, "My Selection (Bar 1) for: A");
+ assert.strictEqual(items[4].innerText, "My Selection (Bar 1) for: AA");
+ assert.strictEqual(items[5].innerText, "My Tags (Bar 1) for: A");
+ assert.strictEqual(items[6].innerText, "My Tags (Bar 1) for: AA");
+ assert.strictEqual(items[7].innerText, "My Text (Bar 2) for: a");
+
+ // click again on the expand icon to hide the properties
+ await click(target.querySelector(".o_expand"));
+ items = target.querySelectorAll(".o_searchview_input_container li");
+ assert.strictEqual(items.length, 1, "Should have hidden the properties");
+
+ // search for a partner, and expand the many2many property
+ await editSearch(target, "Bo");
+ await click(target.querySelector(".o_expand"));
+ await click(target.querySelector("li:nth-child(3) .o_expand"));
+ items = target.querySelectorAll(".o_searchview_input_container li");
+ assert.strictEqual(items.length, 6);
+ assert.strictEqual(items[3].innerText, "Bob");
+ assert.strictEqual(items[4].innerText, "Bobby");
+
+ // fold all the properties (included the search result)
+ await click(target.querySelector(".o_expand"));
+ items = target.querySelectorAll(".o_searchview_input_container li");
+ assert.strictEqual(items.length, 1, "Should have folded the properties");
+
+ // unfold all the properties but fold the search result
+ await click(target.querySelector(".o_expand"));
+ await click(target.querySelector("li:nth-child(3) .o_expand"));
+ items = target.querySelectorAll(".o_searchview_input_container li");
+ assert.strictEqual(items.length, 4, "Should have unfolded the properties");
+ assert.strictEqual(items[1].innerText, "My Partner (Bar 1)");
+ assert.strictEqual(items[2].innerText, "My Partners (Bar 1)");
+ assert.strictEqual(items[3].innerText, "My Text (Bar 2) for: Bo");
+
+ // select Bobby
+ await click(target.querySelector("li:nth-child(3) .o_expand"));
+ await click(target.querySelector(".o_searchview_input_container li:nth-child(5)"));
+ assert.deepEqual(getDomain(controlPanel), [
+ "&",
+ ["bar", "=", 1],
+ ["properties.my_partners", "in", 6],
+ ]);
+
+ // expand the selection properties
+ await click(target.querySelector(".o_control_panel"));
+ await editSearch(target, "a");
+ await click(target.querySelector(".o_expand"));
+ items = target.querySelectorAll(".o_searchview_input_container li");
+ assert.strictEqual(items.length, 8, "Should have unfolded the selection properties");
+ assert.strictEqual(items[3].innerText, "My Selection (Bar 1) for: A");
+ assert.strictEqual(items[4].innerText, "My Selection (Bar 1) for: AA");
+
+ // select the selection option "AA"
+ await click(target.querySelector(".o_searchview_input_container li:nth-child(5)"));
+ let expectedDomain = [
+ "&",
+ "&",
+ ["bar", "=", 1],
+ ["properties.my_partners", "in", 6],
+ "&",
+ ["bar", "=", 1],
+ ["properties.my_selection", "=", "aa"],
+ ];
+ assert.deepEqual(getDomain(controlPanel), expectedDomain);
+
+ // select the selection option "A"
+ await click(target.querySelector(".o_control_panel"));
+ await editSearch(target, "a");
+ await click(target.querySelector(".o_expand"));
+ await click(target.querySelector(".o_searchview_input_container li:nth-child(4)"));
+ expectedDomain = [
+ "&",
+ "&",
+ ["bar", "=", 1],
+ ["properties.my_partners", "in", 6],
+ "|",
+ "&",
+ ["bar", "=", 1],
+ ["properties.my_selection", "=", "aa"],
+ "&",
+ ["bar", "=", 1],
+ ["properties.my_selection", "=", "a"],
+ ];
+ assert.deepEqual(getDomain(controlPanel), expectedDomain);
+
+ // reset the search
+ await click(target.querySelector(".o_facet_remove"));
+ await click(target.querySelector(".o_facet_remove"));
+
+ // search a many2one value
+ await click(target.querySelector(".o_control_panel"));
+ await editSearch(target, "Ali");
+ await click(target.querySelector(".o_expand"));
+ await click(target.querySelector("li:nth-child(2) .o_expand"));
+ items = target.querySelectorAll(".o_searchview_input_container li");
+ assert.strictEqual(items.length, 6, "Should show the search result");
+ assert.strictEqual(items[2].innerText, "Alice");
+ assert.strictEqual(items[3].innerText, "Alicia");
+ await click(target.querySelector(".o_searchview_input_container li:nth-child(4)"));
+ expectedDomain = ["&", ["bar", "=", 1], ["properties.my_partner", "=", 10]];
+ assert.deepEqual(getDomain(controlPanel), expectedDomain);
+
+ // search a tag value
+ await click(target.querySelector(".o_control_panel"));
+ await editSearch(target, "A");
+ await click(target.querySelector(".o_expand"));
+ items = target.querySelectorAll(".o_searchview_input_container li");
+ assert.strictEqual(items.length, 8, "Should show the search result");
+ assert.strictEqual(items[5].innerText, "My Tags (Bar 1) for: A");
+ assert.strictEqual(items[6].innerText, "My Tags (Bar 1) for: AA");
+
+ await click(target.querySelector(".o_searchview_input_container li:nth-child(7)"));
+ expectedDomain = [
+ "&",
+ "&",
+ ["bar", "=", 1],
+ ["properties.my_partner", "=", 10],
+ "&",
+ ["bar", "=", 1],
+ ["properties.my_tags", "in", "aa"],
+ ];
+ assert.deepEqual(getDomain(controlPanel), expectedDomain);
+ // add the tag "B"
+ await click(target.querySelector(".o_control_panel"));
+ await editSearch(target, "B");
+ await click(target.querySelector(".o_expand"));
+ items = target.querySelectorAll(".o_searchview_input_container li");
+ assert.strictEqual(items.length, 6, "Should show the search result");
+ assert.strictEqual(items[4].innerText, "My Tags (Bar 1) for: B");
+ await click(target.querySelector(".o_searchview_input_container li:nth-child(5)"));
+ expectedDomain = [
+ "&",
+ "&",
+ ["bar", "=", 1],
+ ["properties.my_partner", "=", 10],
+ "|",
+ "&",
+ ["bar", "=", 1],
+ ["properties.my_tags", "in", "aa"],
+ "&",
+ ["bar", "=", 1],
+ ["properties.my_tags", "in", "b"],
+ ];
+ assert.deepEqual(getDomain(controlPanel), expectedDomain);
+
+ // try to click on the many2one properties without unfolding
+ // it should not add the domain, but unfold the item
+ await editSearch(target, "Bobby");
+ await click(target.querySelector(".o_expand"));
+ await click(target.querySelector(".o_searchview_input_container li:nth-child(2)"));
+ assert.deepEqual(getDomain(controlPanel), expectedDomain);
+ items = target.querySelectorAll(".o_searchview_input_container li");
+ assert.strictEqual(items.length, 5, "Should have unfold the many2one");
+ assert.strictEqual(items[2].innerText, "(no result)", "Should have unfold the many2one");
+
+ // test the navigation with keyboard
+ await editSearch(target, "Bo");
+ const focusedItem = () => {
+ return target.querySelector(".o_menu_item.focus");
+ };
+ assert.strictEqual(focusedItem().innerText, "Search Properties");
+ // unfold the properties field
+ await triggerEvent(document.activeElement, null, "keydown", { key: "ArrowRight" });
+ assert.strictEqual(focusedItem().innerText, "Search Properties");
+ assert.ok(focusedItem().querySelector(".fa-caret-down"));
+ // move on the many2one property
+ await triggerEvent(document.activeElement, null, "keydown", { key: "ArrowRight" });
+ assert.strictEqual(focusedItem().innerText, "My Partner (Bar 1)");
+ assert.ok(focusedItem().querySelector(".fa-caret-right"));
+ // move on the many2many property
+ await triggerEvent(document.activeElement, null, "keydown", { key: "ArrowDown" });
+ assert.strictEqual(focusedItem().innerText, "My Partners (Bar 1)");
+ assert.ok(focusedItem().querySelector(".fa-caret-right"));
+ // move on the many2one property again
+ await triggerEvent(document.activeElement, null, "keydown", { key: "ArrowUp" });
+ assert.strictEqual(focusedItem().innerText, "My Partner (Bar 1)");
+ assert.ok(focusedItem().querySelector(".fa-caret-right"));
+ // unfold the many2one
+ await triggerEvent(document.activeElement, null, "keydown", { key: "ArrowRight" });
+ assert.strictEqual(focusedItem().innerText, "My Partner (Bar 1)");
+ assert.ok(focusedItem().querySelector(".fa-caret-down"));
+ // select the first many2one
+ await triggerEvent(document.activeElement, null, "keydown", { key: "ArrowRight" });
+ assert.strictEqual(focusedItem().innerText, "Bob");
+ // go up on the parent
+ await triggerEvent(document.activeElement, null, "keydown", { key: "ArrowLeft" });
+ assert.strictEqual(focusedItem().innerText, "My Partner (Bar 1)");
+ assert.ok(focusedItem().querySelector(".fa-caret-down"));
+ // fold the parent
+ await triggerEvent(document.activeElement, null, "keydown", { key: "ArrowLeft" });
+ assert.strictEqual(focusedItem().innerText, "My Partner (Bar 1)");
+ assert.ok(focusedItem().querySelector(".fa-caret-right"));
+ // go up on the properties field
+ await triggerEvent(document.activeElement, null, "keydown", { key: "ArrowLeft" });
+ assert.strictEqual(focusedItem().innerText, "Search Properties");
+ assert.ok(focusedItem().querySelector(".fa-caret-down"));
+ // fold the properties field
+ await triggerEvent(document.activeElement, null, "keydown", { key: "ArrowLeft" });
+ assert.strictEqual(focusedItem().innerText, "Search Properties");
+ assert.ok(focusedItem().querySelector(".fa-caret-right"));
+ });
+
+ QUnit.test("search a property: definition record id in the context", async function (assert) {
+ assert.expect(3);
+
+ async function mockRPC(route, { method, model, args, kwargs }) {
+ if (
+ method === "web_search_read" &&
+ model === "partner" &&
+ kwargs.fields[0] === "display_name" &&
+ kwargs.fields[1] === "child_properties"
+ ) {
+ assert.deepEqual(
+ kwargs.domain,
+ [["id", "=", 2]],
+ "Should search only the active parent properties"
+ );
+
+ const definition2 = [
+ {
+ type: "char",
+ string: "My Text",
+ name: "my_text",
+ },
+ ];
+
+ return {
+ records: [{ id: 2, display_name: "Bar 2", child_properties: definition2 }],
+ };
+ }
+ }
+
+ await makeWithSearch({
+ serverData,
+ resModel: "partner",
+ Component: ControlPanel,
+ searchMenuTypes: [],
+ searchViewId: false,
+ searchViewArch: `
+
+
+
+ `,
+ mockRPC,
+ context: { active_id: 2 },
+ });
+
+ await click(target.querySelector(".o_control_panel"));
+ await editSearch(target, "a");
+ await click(target.querySelector(".o_expand"));
+
+ const items = target.querySelectorAll(".o_searchview_input_container li");
+ assert.strictEqual(items.length, 2, "Should show the search result");
+ assert.strictEqual(items[1].innerText, "My Text (Bar 2) for: a");
+ });
});
diff --git a/addons/web/static/tests/search/search_model_tests.js b/addons/web/static/tests/search/search_model_tests.js
index d7b45266379..10fd547c0fa 100644
--- a/addons/web/static/tests/search/search_model_tests.js
+++ b/addons/web/static/tests/search/search_model_tests.js
@@ -49,6 +49,12 @@ QUnit.module("Search", (hooks) => {
date_field: { string: "Date", type: "date", store: true, sortable: true },
float_field: { string: "Float", type: "float" },
bar: { string: "Bar", type: "many2one", relation: "partner" },
+ properties: {
+ string: "Properties",
+ type: "properties",
+ definition_record: "bar",
+ definition_record_field: "child_properties",
+ },
},
records: [],
},
@@ -958,4 +964,31 @@ QUnit.module("Search", (hooks) => {
model.toggleSearchItem(1);
assert.deepEqual(model.domain, [["date_deadline", "<", "2021-09-17"]]);
});
+
+ QUnit.test(
+ "a field of type 'properties' should not be accepted as a search_default",
+ async function (assert) {
+ const searchViewArch = `
+
+
+
+ `;
+
+ const model = await makeSearchModel({
+ serverData,
+ searchViewArch,
+ context: {
+ search_default_properties: true,
+ },
+ });
+ assert.deepEqual(sanitizeSearchItems(model), [
+ {
+ description: "Properties",
+ fieldName: "properties",
+ fieldType: "properties",
+ type: "field",
+ },
+ ]);
+ }
+ );
});
diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py
index 0494c0adc6c..67d48ab45b0 100644
--- a/odoo/addons/base/models/ir_ui_view.py
+++ b/odoo/addons/base/models/ir_ui_view.py
@@ -2744,7 +2744,7 @@ class Model(models.AbstractModel):
:rtype: list
"""
return [
- 'change_default', 'context', 'currency_field', 'definition_record', 'digits', 'domain', 'group_operator', 'groups',
+ 'change_default', 'context', 'currency_field', 'definition_record', 'definition_record_field', 'digits', 'domain', 'group_operator', 'groups',
'help', 'name', 'readonly', 'related', 'relation', 'relation_field', 'required', 'searchable', 'selection', 'size',
'sortable', 'store', 'string', 'translate', 'trim', 'type',
]