[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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user