[IMP] web: allow to search properties fields using the top search bar

Purpose
=======
Add the properties fields in the search bar and allow to search
properties fields values.

Technical
=========
If the "active_id" is in the context, show only the properties
of the given definition record. Otherwise, show all properties
of all definition records.

Because fetching the properties definitions cost a SQL query and a RPC
call, the properties definition are fetched only if we unfold the
properties field (so it has no performance impact until we ask
explicitly to search a property).

Task-2980121

Part-of: odoo/odoo#101901
Co-authored-by: Polymorphe57 <dam@odoo.com>
This commit is contained in:
std-odoo
2023-03-07 01:59:05 +01:00
co-authored by Polymorphe57
parent 0583a5ddd0
commit defa390621
7 changed files with 697 additions and 123 deletions
@@ -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];
});
});
}
}
@@ -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();
@@ -55,20 +55,18 @@
t-on-mousemove="() => this.onItemMousemove(item_index)"
>
<t t-if="item.isParent">
<a class="o_expand"
<a t-att-class="{ o_expand: true, 'ms-4': item.isFieldProperty}"
href="#"
t-on-click.stop.prevent="() => this.toggleItem(item, !item.isExpanded)"
>
<i t-attf-class="fa fa-caret-{{ item.isExpanded ? 'down' : 'right' }}"/>
</a>
</t>
<a href="#" t-on-click.prevent="">
<a href="#" t-on-click.prevent="" t-att-class="{'ps-3 pe-2 text-truncate': item.isFieldProperty }" t-att-title="item.title">
<t t-if="item.isChild">
<t t-esc="item.label"/>
</t>
<t t-else="">
Search <b t-esc="item.searchItemDescription"/> <t t-esc="item.preposition"/>: <b class="fst-italic text-primary" t-esc="item.label"/>
</t>
<t t-elif="!item.isFieldProperty" > Search </t> <b t-esc="item.searchItemDescription"/> <t t-if="item.preposition"> <t t-esc="item.preposition"/>: <b class="fst-italic text-primary" t-esc="item.label"/> </t>
</a>
</li>
</t>
+123 -24
View File
@@ -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: <id of the parent record>
* definitionRecordName: <display name of the parent record>
* definitions: <list of properties 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);
}
@@ -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: `
<search>
<field name="properties"/>
</search>
`,
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: `
<search>
<field name="properties"/>
</search>
`,
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");
});
});
@@ -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 = `
<search>
<field name="properties"/>
</search>
`;
const model = await makeSearchModel({
serverData,
searchViewArch,
context: {
search_default_properties: true,
},
});
assert.deepEqual(sanitizeSearchItems(model), [
{
description: "Properties",
fieldName: "properties",
fieldType: "properties",
type: "field",
},
]);
}
);
});
+1 -1
View File
@@ -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',
]