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', ]