import { makeContext } from "@web/core/context"; import { Domain } from "@web/core/domain"; import { evaluateExpr } from "@web/core/py_js/py"; import { user } from "@web/core/user"; import { sortBy, groupBy } from "@web/core/utils/arrays"; import { deepCopy } from "@web/core/utils/objects"; import { SearchArchParser } from "./search_arch_parser"; import { constructDateDomain, DEFAULT_INTERVAL, getComparisonOptions, getIntervalOptions, getPeriodOptions, rankInterval, yearSelected, } from "./utils/dates"; import { FACET_ICONS, FACET_COLORS } from "./utils/misc"; import { EventBus, toRaw } from "@odoo/owl"; import { domainFromTree, treeFromDomain } from "@web/core/tree_editor/condition_tree"; import { _t } from "@web/core/l10n/translation"; import { useGetTreeDescription, useMakeGetFieldDef } from "@web/core/tree_editor/utils"; import { DomainSelectorDialog } from "@web/core/domain_selector_dialog/domain_selector_dialog"; import { getDefaultDomain } from "@web/core/domain_selector/utils"; const { DateTime } = luxon; /** @typedef {import("@web/core/domain").DomainRepr} DomainRepr */ /** @typedef {import("@web/core/domain").DomainListRepr} DomainListRepr */ /** @typedef {import("../views/utils").OrderTerm} OrderTerm */ /** * @typedef {Object} ComparisonDomain * @property {DomainListRepr} arrayRepr * @property {string} description */ /** * @typedef {Object} Comparison * @property {ComparisonDomain[]} domains * @property {string} [fieldName] */ /** * @typedef {Object} SearchParams * @property {Comparison | null} comparison * @property {Context} context * @property {DomainListRepr} domain * @property {string[]} groupBy * @property {OrderTerm[]} orderBy * @property {boolean} [useSampleModel] to remove? */ /** @todo rework doc */ // interface SectionCommon { // check optional keys // color: string; // description: string; // errorMsg: [string]; // enableCounters: boolean; // expand: boolean; // fieldName: string; // icon: string; // id: number; // limit: number; // values: Map; // } // export interface Category extends SectionCommon { // type: "category"; // hierarchize: boolean; // } // export interface Filter extends SectionCommon { // type: "filter"; // domain: string; // groupBy: string; // groups: Map; // } // export type Section = Category | Filter; // export type SectionPredicate = (section: Section) => boolean; /** * @param {Section} section * @returns {boolean} */ function hasValues(section) { const { errorMsg, type, values } = section; if (errorMsg) { return true; } switch (type) { case "category": { return values && values.size > 1; // false item ignored } case "filter": { return values && values.size > 0; } } } /** * Returns a serialised array of the given map with its values being the * shallow copies of the original values. * @param {Map} map * @return {Array[]} */ function mapToArray(map) { const result = []; for (const [key, val] of map) { const valCopy = Object.assign({}, val); result.push([key, valCopy]); } return result; } /** * @param {Array[]} * @returns {Map} map */ function arraytoMap(array) { return new Map(array); } /** * @param {Function} op * @param {Object} source * @param {Object} target */ function execute(op, source, target) { const { query, nextId, nextGroupId, nextGroupNumber, searchItems, searchPanelInfo, sections } = source; target.nextGroupId = nextGroupId; target.nextGroupNumber = nextGroupNumber; target.nextId = nextId; target.query = query; target.searchItems = searchItems; target.searchPanelInfo = searchPanelInfo; target.sections = op(sections); for (const [, section] of target.sections) { section.values = op(section.values); if (section.groups) { section.groups = op(section.groups); for (const [, group] of section.groups) { group.values = op(group.values); } } } } //-------------------------------------------------------------------------- // Global constants/variables //-------------------------------------------------------------------------- const FAVORITE_PRIVATE_GROUP = 1; const FAVORITE_SHARED_GROUP = 2; export class SearchModel extends EventBus { constructor(env, services, args) { super(); this.env = env; this.setup(services, args); } /** * @override */ setup(services) { // services const { field: fieldService, name: nameService, orm, view, dialog } = services; this.orm = orm; this.fieldService = fieldService; this.viewService = view; this.dialog = dialog; this.orderByCount = false; this.getDomainTreeDescription = useGetTreeDescription(fieldService, nameService); this.makeGetFieldDef = useMakeGetFieldDef(fieldService); // used to manage search items related to date/datetime fields this.referenceMoment = DateTime.local(); this.comparisonOptions = getComparisonOptions(); this.intervalOptions = getIntervalOptions(); } /** * * @param {Object} config * @param {string} config.resModel * * @param {string} [config.searchViewArch=""] * @param {Object} [config.searchViewFields={}] * @param {number|false} [config.searchViewId=false] * @param {Object[]} [config.irFilters=[]] * * @param {boolean} [config.activateFavorite=true] * @param {Object | null} [config.comparison] * @param {Object} [config.context={}] * @param {Array} [config.domain=[]] * @param {Array} [config.dynamicFilters=[]] * @param {string[]} [config.groupBy=[]] * @param {boolean} [config.loadIrFilters=false] * @param {boolean} [config.display.searchPanel=true] * @param {OrderTerm[]} [config.orderBy=[]] * @param {string[]} [config.searchMenuTypes=["filter", "groupBy", "favorite"]] * @param {Object} [config.state] */ async load(config) { const { resModel } = config; if (!resModel) { throw Error(`SearchPanel config should have a "resModel" key`); } this.resModel = resModel; // used to avoid useless recomputations this._reset(); const { comparison, context, domain, groupBy, hideCustomGroupBy, orderBy } = config; this.globalComparison = comparison; this.globalContext = toRaw(Object.assign({}, context)); this.globalDomain = domain || []; this.globalGroupBy = groupBy || []; this.globalOrderBy = orderBy || []; this.hideCustomGroupBy = hideCustomGroupBy; this.searchMenuTypes = new Set(config.searchMenuTypes || ["filter", "groupBy", "favorite"]); this.canOrderByCount = config.canOrderByCount; let { irFilters, loadIrFilters, searchViewArch, searchViewFields, searchViewId } = config; const loadSearchView = searchViewId !== undefined && (!searchViewArch || !searchViewFields || (!irFilters && loadIrFilters)); const searchViewDescription = {}; if (loadSearchView) { const result = await this.viewService.loadViews( { context: this.globalContext, resModel, views: [[searchViewId, "search"]], }, { actionId: this.env.config.actionId, embeddedActionId: this.env.config.currentEmbeddedActionId, loadIrFilters: loadIrFilters || false, } ); Object.assign(searchViewDescription, result.views.search); searchViewFields = searchViewFields || result.fields; } if (searchViewArch) { searchViewDescription.arch = searchViewArch; } if (irFilters) { searchViewDescription.irFilters = irFilters; } if (searchViewId !== undefined) { searchViewDescription.viewId = searchViewId; } this.searchViewArch = searchViewDescription.arch || ""; this.searchViewFields = searchViewFields || {}; if (searchViewDescription.irFilters) { this.irFilters = searchViewDescription.irFilters; } if (searchViewDescription.viewId !== undefined) { this.searchViewId = searchViewDescription.viewId; } const { searchDefaults, searchPanelDefaults } = this._extractSearchDefaultsFromGlobalContext(); if (config.state) { this._importState(config.state); this.__legacyParseSearchPanelArchAnyway(searchViewDescription, searchViewFields); this.display = this._getDisplay(config.display); if (!this.searchPanelInfo.loaded) { return this._reloadSections(); } return; } this.blockNotification = true; this.searchItems = {}; this.query = []; this.nextId = 1; this.nextGroupId = 1; this.nextGroupNumber = 1; const parser = new SearchArchParser( searchViewDescription, searchViewFields, searchDefaults, searchPanelDefaults ); const { labels, preSearchItems, searchPanelInfo, sections } = parser.parse(); this.searchPanelInfo = { ...searchPanelInfo, loaded: false, shouldReload: false }; await Promise.all(labels.map((cb) => cb(this.orm))); // prepare search items (populate this.searchItems) for (const preGroup of preSearchItems || []) { this._createGroupOfSearchItems(preGroup); } this.nextGroupNumber = 1 + Math.max(...Object.values(this.searchItems).map((i) => i.groupNumber || 0), 0); const dateFilters = Object.values(this.searchItems).filter( (searchElement) => searchElement.type === "dateFilter" ); if (dateFilters.length) { this._createGroupOfComparisons(dateFilters); } const { dynamicFilters } = config; if (dynamicFilters) { this._createGroupOfDynamicFilters(dynamicFilters); } const defaultFavoriteId = this._createGroupOfFavorites(this.irFilters || []); const activateFavorite = "activateFavorite" in config ? config.activateFavorite : true; // activate default search items (populate this.query) this._activateDefaultSearchItems(activateFavorite ? defaultFavoriteId : null); // prepare search panel sections /** @type Map */ this.sections = new Map(sections || []); this.display = this._getDisplay(config.display); if (this.display.searchPanel) { /** @type DomainListRepr */ this.searchDomain = this._getDomain({ withSearchPanel: false }); this.sectionsPromise = this._fetchSections(this.categories, this.filters).then(() => { for (const { fieldName, values } of this.filters) { const filterDefaults = searchPanelDefaults[fieldName] || []; for (const valueId of filterDefaults) { const value = values.get(valueId); if (value) { value.checked = true; } } } }); if (Object.keys(searchPanelDefaults).length || this._shouldWaitForData(false)) { await this.sectionsPromise; } } this.blockNotification = false; } /** * @param {Object} [config={}] * @param {Object | null} [config.comparison] * @param {Object} [config.context={}] * @param {Array} [config.domain=[]] * @param {string[]} [config.groupBy=[]] * @param {OrderTerm[]} [config.orderBy=[]] */ async reload(config = {}) { this._reset(); const { comparison, context, domain, groupBy, orderBy } = config; this.globalContext = Object.assign({}, context); this.globalDomain = domain || []; this.globalComparison = comparison; this.globalGroupBy = groupBy || []; this.globalOrderBy = orderBy || []; this._extractSearchDefaultsFromGlobalContext(); await this._reloadSections(); } //-------------------------------------------------------------------------- // Getters //-------------------------------------------------------------------------- /** * @returns {Category[]} */ get categories() { return [...this.sections.values()].filter((s) => s.type === "category"); } /** * @returns {Context} should be imported from context.js? */ get context() { if (!this._context) { this._context = makeContext([this.globalContext, this._getContext()]); } return deepCopy(this._context); } /** * @returns {DomainListRepr} */ get domain() { if (!this._domain) { this._domain = this._getDomain(); } return deepCopy(this._domain); } /** * @returns {string} */ get domainString() { return this._getDomain({ raw: true }).toString(); } get domainEvalContext() { return Object.assign({}, this.globalContext, user.context); } /** * @returns {Comparison} */ get comparison() { if (!this.searchMenuTypes.has("comparison")) { return null; } if (this._comparison === undefined) { if (this.globalComparison) { this._comparison = this.globalComparison; } else { const comparison = this.getFullComparison(); if (comparison) { const { fieldName, range, rangeDescription, comparisonRange, comparisonRangeDescription, } = comparison; const domains = [ { arrayRepr: Domain.and([this.domain, range]).toList(), description: rangeDescription, }, { arrayRepr: Domain.and([this.domain, comparisonRange]).toList(), description: comparisonRangeDescription, }, ]; this._comparison = { domains, fieldName }; } else { this._comparison = null; } } } return deepCopy(this._comparison); } get facets() { const isValidType = (type) => !["groupBy", "comparison"].includes(type) || this.searchMenuTypes.has(type); const facets = []; for (const facet of this._getFacets()) { if (!isValidType(facet.type)) { continue; } facets.push(facet); } return facets; } /** * @returns {Filter[]} */ get filters() { return [...this.sections.values()].filter((s) => s.type === "filter"); } /** * @returns {string[]} */ get groupBy() { if (!this.searchMenuTypes.has("groupBy")) { return []; } if (!this._groupBy) { this._groupBy = this._getGroupBy(); } return deepCopy(this._groupBy); } /** * @returns {OrderTerm[]} */ get orderBy() { if (!this._orderBy) { this._orderBy = this._getOrderBy(); } return deepCopy(this._orderBy); } get isDebugMode() { return !!this.env.debug; } //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Activate a filter of type 'field' with given filterId with * 'autocompleteValues' value, label, and operator. * @param {Object} */ addAutoCompletionValues(searchItemId, autocompleteValue) { const searchItem = this.searchItems[searchItemId]; if (!["field", "field_property"].includes(searchItem.type)) { return; } const { label, value, operator } = autocompleteValue; const queryElem = this.query.find( (queryElem) => queryElem.searchItemId === searchItemId && "autocompleteValue" in queryElem && queryElem.autocompleteValue.value === value && queryElem.autocompleteValue.operator === operator ); if (!queryElem) { this.query.push({ searchItemId, autocompleteValue }); } else { queryElem.autocompleteValue.label = label; // seems related to old stuff --> should be useless now } this._notify(); } /** * Remove all the query elements from query. */ clearQuery() { this.query = []; this.orderByCount = false; this._notify(); } /** * Create a new filter of type 'favorite' and activate it. * A new group containing only that filter is created. * The query is emptied before activating the new favorite. * @param {Object} params * @returns {Promise} */ async createNewFavorite(params) { const { preFavorite, irFilter } = this._getIrFilterDescription(params); const serverSideId = await this._createIrFilters(irFilter); // before the filter cache was cleared! this.blockNotification = true; this.clearQuery(); const favorite = { ...preFavorite, type: "favorite", id: this.nextId, groupId: this.nextGroupId, groupNumber: preFavorite.userId ? FAVORITE_PRIVATE_GROUP : FAVORITE_SHARED_GROUP, removable: true, serverSideId, }; this.searchItems[this.nextId] = favorite; this.query.push({ searchItemId: this.nextId }); this.nextGroupId++; this.nextId++; this.blockNotification = false; this._notify(); } async _createIrFilters(irFilter) { const serverSideId = await this.orm.call("ir.filters", "create_or_replace", [irFilter]); this.env.bus.trigger("CLEAR-CACHES"); return serverSideId; } /** * Create new search items of type 'filter' and activate them. * A new group containing only those filters is created. */ createNewFilters(prefilters) { if (!prefilters.length) { return []; } prefilters.forEach((preFilter) => { const filter = Object.assign(preFilter, { groupId: this.nextGroupId, groupNumber: this.nextGroupNumber, id: this.nextId, type: "filter", }); this.searchItems[this.nextId] = filter; this.query.push({ searchItemId: this.nextId }); this.nextId++; }); this.nextGroupId++; this.nextGroupNumber++; this._notify(); } /** * Create a new filter of type 'groupBy' or 'dateGroupBy' and activate it. * It is added to the unique group of groupbys. * @param {string} fieldName * @param {Object} [param] * @param {string} [param.interval=DEFAULT_INTERVAL] * @param {boolean} [param.invisible=false] */ createNewGroupBy(fieldName, { interval, invisible } = {}) { const field = this.searchViewFields[fieldName]; const { string, type: fieldType } = field; const firstGroupBy = Object.values(this.searchItems).find((f) => f.type === "groupBy"); const preSearchItem = { description: string || fieldName, fieldName, fieldType, groupId: firstGroupBy ? firstGroupBy.groupId : this.nextGroupId++, groupNumber: this.nextGroupNumber, id: this.nextId, custom: true, }; if (invisible) { preSearchItem.invisible = "True"; } if (["date", "datetime"].includes(fieldType)) { this.searchItems[this.nextId] = Object.assign( { type: "dateGroupBy", defaultIntervalId: interval || DEFAULT_INTERVAL }, preSearchItem ); this.toggleDateGroupBy(this.nextId); } else { this.searchItems[this.nextId] = Object.assign({ type: "groupBy" }, preSearchItem); this.toggleSearchItem(this.nextId); } this.nextGroupNumber++; // FIXME: with this, all subsequent added groups are in different groups (visually) this.nextId++; this._notify(); } /** * Deactivate a group with provided groupId, i.e. delete the query elements * with given groupId. */ deactivateGroup(groupId) { this.query = this.query.filter((queryElem) => { const searchItem = this.searchItems[queryElem.searchItemId]; return searchItem.groupId !== groupId; }); this._checkComparisonStatus(); this._checkOrderByCountStatus(); this._notify(); } /** * Delete a filter of type 'favorite' with given this.nextId server side and * in control panel model. Of course the filter is also removed * from the search query. */ async deleteFavorite(favoriteId) { const searchItem = this.searchItems[favoriteId]; if (searchItem.type !== "favorite") { return; } await this._deleteIrFilters(searchItem); const index = this.query.findIndex((queryElem) => queryElem.searchItemId === favoriteId); delete this.searchItems[favoriteId]; if (index >= 0) { this.query.splice(index, 1); } this._notify(); } async _deleteIrFilters(searchItem) { const { serverSideId } = searchItem; await this.orm.unlink("ir.filters", [serverSideId]); this.env.bus.trigger("CLEAR-CACHES"); } /** * @returns {Object} */ exportState() { const state = {}; execute(mapToArray, this, state); return state; } getFullComparison() { let searchItem = null; for (const queryElem of this.query.slice().reverse()) { const item = this.searchItems[queryElem.searchItemId]; if (item.type === "comparison") { searchItem = item; break; } else if (item.type === "favorite" && item.comparison) { searchItem = item; break; } } if (!searchItem) { return null; } else if (searchItem.type === "favorite") { return searchItem.comparison; } const { dateFilterId, comparisonOptionId } = searchItem; const dateFilter = this.searchItems[dateFilterId]; const { fieldName, description: dateFilterDescription } = dateFilter; const selectedGeneratorIds = this._getSelectedGeneratorIds(dateFilterId); // compute range and range description const { domain: range, description: rangeDescription } = constructDateDomain( this.referenceMoment, dateFilter, selectedGeneratorIds ); // compute comparisonRange and comparisonRange description const { domain: comparisonRange, description: comparisonRangeDescription } = constructDateDomain( this.referenceMoment, dateFilter, selectedGeneratorIds, comparisonOptionId ); return { comparisonId: comparisonOptionId, fieldName, fieldDescription: dateFilterDescription, range: range.toList(), rangeDescription, comparisonRange: comparisonRange.toList(), comparisonRangeDescription, }; } getIrFilterValues(params) { const { irFilter } = this._getIrFilterDescription(params); return irFilter; } getPreFavoriteValues(params) { const { preFavorite } = this._getIrFilterDescription(params); return preFavorite; } /** * Return an array containing enriched copies of all searchElements or of those * satifying the given predicate if any * @param {Function} [predicate] * @returns {Object[]} */ getSearchItems(predicate) { const searchItems = []; for (const searchItem of Object.values(this.searchItems)) { const enrichedSearchitem = this._enrichItem(searchItem); if (enrichedSearchitem) { const isInvisible = "invisible" in searchItem && evaluateExpr(searchItem.invisible, this.globalContext); if (!isInvisible && (!predicate || predicate(enrichedSearchitem))) { searchItems.push(enrichedSearchitem); } } } if (searchItems.some((f) => f.type === "favorite")) { searchItems.sort((f1, f2) => f1.groupNumber - f2.groupNumber); } return searchItems; } /** * Returns a sorted list of a copy of all sections. This list can be * filtered by a given predicate. * @param {SectionPredicate} [predicate] used to determine * which subsets of sections is wanted * @returns {Section[]} */ getSections(predicate) { let sections = [...this.sections.values()].map((section) => Object.assign({}, section, { empty: !hasValues(section) }) ); if (predicate) { sections = sections.filter(predicate); } return sections.sort((s1, s2) => s1.index - s2.index); } search() { this.trigger("update"); } async splitAndAddDomain(domain, groupId) { const group = groupId ? this._getGroups().find((g) => g.id === groupId) : null; let context; if (group) { const contexts = []; for (const activeItem of group.activeItems) { const context = this._getSearchItemContext(activeItem); if (context) { contexts.push(context); } } context = makeContext(contexts); } const getFieldDef = await this.makeGetFieldDef(this.resModel, treeFromDomain(domain)); const tree = treeFromDomain(domain, { distributeNot: !this.isDebugMode, getFieldDef }); const trees = !tree.negate && tree.value === "&" ? tree.children : [tree]; const promises = trees.map(async (tree) => { const description = await this.getDomainTreeDescription(this.resModel, tree); const preFilter = { description, domain: domainFromTree(tree), invisible: "True", type: "filter", }; if (context) { preFilter.context = context; } return preFilter; }); const preFilters = await Promise.all(promises); this.blockNotification = true; if (group) { const firstActiveItem = group.activeItems[0]; const firstSearchItem = this.searchItems[firstActiveItem.searchItemId]; const { type } = firstSearchItem; if (type === "favorite") { const activeItemGroupBys = this._getSearchItemGroupBys(firstActiveItem); for (const activeItemGroupBy of activeItemGroupBys) { const [fieldName, interval] = activeItemGroupBy.split(":"); this.createNewGroupBy(fieldName, { interval, invisible: true }); } const index = this.query.length - activeItemGroupBys.length; this.query = [...this.query.slice(index), ...this.query.slice(0, index)]; } this.deactivateGroup(groupId); } for (const preFilter of preFilters) { this.createNewFilters([preFilter]); } this.blockNotification = false; this._notify(); } /** * Set the active value id of a given category. * @param {number} sectionId * @param {number} valueId */ toggleCategoryValue(sectionId, valueId) { const category = this.sections.get(sectionId); category.activeValueId = valueId; this._notify(); } /** * Toggle a filter value of a given section. The value will be set * to "forceTo" if provided, else it will be its own opposed value. * @param {number} sectionId * @param {number[]} valueIds * @param {boolean} [forceTo=null] */ toggleFilterValues(sectionId, valueIds, forceTo = null) { const filter = this.sections.get(sectionId); for (const valueId of valueIds) { const value = filter.values.get(valueId); value.checked = forceTo === null ? !value.checked : forceTo; } this._notify(); } /** * Clears all values from the provided sections * @param {array} sectionIds */ clearSections(sectionIds) { for (const sectionId of sectionIds) { const section = this.sections.get(sectionId); if (section.type === "category") { section.activeValueId = false; } else { for (const [, value] of section.values) { value.checked = false; } } } this._notify(); } /** * Activate or deactivate the simple filter with given filterId, i.e. * add or remove a corresponding query element. */ toggleSearchItem(searchItemId) { const searchItem = this.searchItems[searchItemId]; switch (searchItem.type) { case "dateFilter": case "dateGroupBy": case "field_property": case "field": { return; } } const index = this.query.findIndex((queryElem) => queryElem.searchItemId === searchItemId); if (index >= 0) { this.query.splice(index, 1); this._checkOrderByCountStatus(); } else { if (searchItem.type === "favorite") { this.query = []; } else if (searchItem.type === "comparison") { // make sure only one comparison can be active this.query = this.query.filter((queryElem) => { const { type } = this.searchItems[queryElem.searchItemId]; return type !== "comparison"; }); } this.query.push({ searchItemId }); } this._notify(); } /** * Used to toggle a query element. * This can impact the query in various form, e.g. add/remove other query elements * in case the filter is of type 'filter'. */ toggleDateFilter(searchItemId, generatorId) { const searchItem = this.searchItems[searchItemId]; if (searchItem.type !== "dateFilter") { return; } const generatorIds = generatorId ? [generatorId] : searchItem.defaultGeneratorIds; for (const generatorId of generatorIds) { const index = this.query.findIndex( (queryElem) => queryElem.searchItemId === searchItemId && "generatorId" in queryElem && queryElem.generatorId === generatorId ); if (index >= 0) { this.query.splice(index, 1); if (!yearSelected(this._getSelectedGeneratorIds(searchItemId))) { // This is the case where generatorId was the last option // of type 'year' to be there before being removed above. // Since other options of type 'month' or 'quarter' do // not make sense without a year we deactivate all options. this.query = this.query.filter( (queryElem) => queryElem.searchItemId !== searchItemId ); } } else { if (generatorId.startsWith("custom")) { const comparisonId = this._getActiveComparison()?.id; this.query = this.query.filter( (queryElem) => ![searchItemId, comparisonId].includes(queryElem.searchItemId) ); this.query.push({ searchItemId, generatorId }); continue; } this.query = this.query.filter( (queryElem) => queryElem.searchItemId !== searchItemId || !queryElem.generatorId.startsWith("custom") ); this.query.push({ searchItemId, generatorId }); if (!yearSelected(this._getSelectedGeneratorIds(searchItemId))) { // Here we add 'year' as options if no option of type // year is already selected. const { defaultYearId } = getPeriodOptions( this.referenceMoment, searchItem.optionsParams ).find((o) => o.id === generatorId); this.query.push({ searchItemId, generatorId: defaultYearId }); } } } this._checkComparisonStatus(); this._notify(); } toggleDateGroupBy(searchItemId, intervalId) { const searchItem = this.searchItems[searchItemId]; if (searchItem.type !== "dateGroupBy") { return; } intervalId = intervalId || searchItem.defaultIntervalId; const index = this.query.findIndex( (queryElem) => queryElem.searchItemId === searchItemId && "intervalId" in queryElem && queryElem.intervalId === intervalId ); if (index >= 0) { this.query.splice(index, 1); this._checkOrderByCountStatus(); } else { this.query.push({ searchItemId, intervalId }); } this._notify(); } async spawnCustomFilterDialog() { const domain = getDefaultDomain(this.searchViewFields); this.dialog.add(DomainSelectorDialog, { resModel: this.resModel, defaultConnector: "|", domain, context: this.domainEvalContext, onConfirm: (domain) => this.splitAndAddDomain(domain), disableConfirmButton: (domain) => domain === `[]`, title: _t("Add Custom Filter"), confirmButtonText: _t("Add"), discardButtonText: _t("Cancel"), isDebugMode: this.isDebugMode, }); } switchGroupBySort() { if (this.orderByCount === "Desc") { this.orderByCount = "Asc"; } else { this.orderByCount = "Desc"; } 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 result = await this._fetchPropertiesDefinition(this.resModel, searchItem.fieldName); 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) { if (definition.type === "separator") { continue; } 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 //-------------------------------------------------------------------------- /** * Because it require a RPC to get the properties search views items, * it's done lazily, only when we need them. */ async fillSearchViewItemsProperty() { if (!this.searchViewFields) { return; } const fields = Object.values(this.searchViewFields); for (const field of fields) { if (field.type !== "properties") { continue; } const result = await this._fetchPropertiesDefinition(this.resModel, field.name); const searchItemsNames = Object.values(this.searchItems) .filter((item) => item.isProperty && ["groupBy", "dateGroupBy"].includes(item.type)) .map((item) => item.fieldName); for (const { definitionRecordId, definitionRecordName, definitions } of result) { // some properties might have been deleted const groupNames = definitions.map( (definition) => `group_by_${field.name}.${definition.name}` ); Object.values(this.searchItems).forEach((searchItem) => { if ( searchItem.isProperty && searchItem.definitionRecordId === definitionRecordId && ["groupBy", "dateGroupBy"].includes(searchItem.type) && !groupNames.includes(searchItem.name) ) { // we can not just remove the element from the list because index are used as id // so we use a different type to hide it everywhere (until the user refresh his // browser and the item won't be created again) searchItem.type = "group_by_property_deleted"; } }); for (const definition of definitions) { // we need the definition of the "field" (fake field, property) to be // in searchViewFields to be able to have the type, it's description, etc // the name of the property is stored as "." const fullName = `${field.name}.${definition.name}`; this.searchViewFields[fullName] = { name: fullName, readonly: false, relation: definition.comodel, required: false, searchable: false, selection: definition.selection, sortable: true, store: true, string: `${definition.string} (${definitionRecordName})`, type: definition.type, relatedPropertyField: field, }; if (!searchItemsNames.includes(fullName)) { const groupByItem = { description: definition.string, definitionRecordId, definitionRecordName, fieldName: fullName, fieldType: definition.type, isProperty: true, name: `group_by_${field.name}.${definition.name}`, propertyFieldName: field.name, type: ["datetime", "date"].includes(definition.type) ? "dateGroupBy" : "groupBy", }; this._createGroupOfSearchItems([groupByItem]); } } } } } /** * Fetch the properties definitions. * * @param {string} definitionRecordModel * @param {string} definitionRecordField * @return {Object[]} A list of objects of the form * { * definitionRecordId: * definitionRecordName: * definitions: * } */ async _fetchPropertiesDefinition(resModel, fieldName) { 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 definitions = await this.fieldService.loadPropertyDefinitions( resModel, fieldName, domain ); const result = groupBy(Object.values(definitions), (definition) => definition.record_id); return Object.entries(result).map(([recordId, definitions]) => { return { definitionRecordId: parseInt(recordId), definitionRecordName: definitions[0]?.record_name, definitions, }; }); } /** * Activate the default favorite (if any) or all default filters. */ _activateDefaultSearchItems(defaultFavoriteId) { if (defaultFavoriteId) { // Activate default favorite this.toggleSearchItem(defaultFavoriteId); } else { // Activate default filters Object.values(this.searchItems) .filter((f) => f.isDefault && f.type !== "favorite") .sort((f1, f2) => (f1.defaultRank || 100) - (f2.defaultRank || 100)) .forEach((f) => { if (f.type === "dateFilter") { this.toggleDateFilter(f.id); } else if (f.type === "dateGroupBy") { this.toggleDateGroupBy(f.id); } else if (f.type === "field") { this.addAutoCompletionValues(f.id, f.defaultAutocompleteValue); } else { this.toggleSearchItem(f.id); } }); } } /** * If a comparison is active, check if it should become inactive. * The comparison should become inactive if the corresponding date filter has become * inactive. */ _checkComparisonStatus() { const activeComparison = this._getActiveComparison(); if (!activeComparison) { return; } const { dateFilterId, id } = activeComparison; const dateFilterIsActive = this.query.some( (queryElem) => queryElem.searchItemId === dateFilterId ); if (!dateFilterIsActive) { this.query = this.query.filter((queryElem) => queryElem.searchItemId !== id); } } _checkOrderByCountStatus() { if ( this.orderByCount && !this.query.some((item) => ["dateGroupBy", "groupBy"].includes(this.searchItems[item.searchItemId].type) ) ) { this.orderByCount = false; } } /** * @param {string} sectionId * @param {Object} result */ _createCategoryTree(sectionId, result) { const category = this.sections.get(sectionId); let { error_msg, parent_field: parentField, values } = result; if (error_msg) { category.errorMsg = error_msg; values = []; } if (category.hierarchize) { category.parentField = parentField; } for (const value of values) { category.values.set( value.id, Object.assign({}, value, { childrenIds: [], parentId: value[parentField] || false, }) ); } for (const value of values) { const { parentId } = category.values.get(value.id); if (parentId && category.values.has(parentId)) { category.values.get(parentId).childrenIds.push(value.id); } } // collect rootIds category.rootIds = [false]; for (const value of values) { const { parentId } = category.values.get(value.id); if (!parentId) { category.rootIds.push(value.id); } } // Set active value from context const valueIds = [false, ...values.map((val) => val.id)]; this._ensureCategoryValue(category, valueIds); } /** * @param {string} sectionId * @param {Object} result */ _createFilterTree(sectionId, result) { const filter = this.sections.get(sectionId); let { error_msg, values } = result; if (error_msg) { filter.errorMsg = error_msg; values = []; } // restore checked property values.forEach((value) => { const oldValue = filter.values.get(value.id); value.checked = oldValue ? oldValue.checked : false; }); filter.values = new Map(); const groupIds = []; if (filter.groupBy) { const groups = new Map(); for (const value of values) { const groupId = value.group_id; if (!groups.has(groupId)) { if (groupId) { groupIds.push(groupId); } groups.set(groupId, { id: groupId, name: value.group_name, values: new Map(), tooltip: value.group_tooltip, sequence: value.group_sequence, color_index: value.color_index, }); // restore former checked state const oldGroup = filter.groups && filter.groups.get(groupId); groups.get(groupId).state = (oldGroup && oldGroup.state) || false; } groups.get(groupId).values.set(value.id, value); } filter.groups = groups; filter.sortedGroupIds = sortBy( groupIds, (id) => groups.get(id).sequence || groups.get(id).name ); for (const group of filter.groups.values()) { for (const [valueId, value] of group.values) { filter.values.set(valueId, value); } } } else { for (const value of values) { filter.values.set(value.id, value); } } } /** * Starting from the array of date filters, create the filters of type * 'comparison'. * @param {Object[]} dateFilters */ _createGroupOfComparisons(dateFilters) { const preSearchItem = []; for (const dateFilter of dateFilters) { for (const comparisonOption of this.comparisonOptions) { const { id: dateFilterId, description } = dateFilter; const preFilter = { type: "comparison", comparisonOptionId: comparisonOption.id, description: `${description}: ${comparisonOption.description}`, dateFilterId, }; preSearchItem.push(preFilter); } } this._createGroupOfSearchItems(preSearchItem); } /** * Add filters of type 'filter' determined by the key array dynamicFilters. */ _createGroupOfDynamicFilters(dynamicFilters) { const pregroup = dynamicFilters.map((filter) => { return { groupNumber: this.nextGroupNumber, description: filter.description, domain: filter.domain, isDefault: "is_default" in filter ? filter.is_default : true, type: "filter", }; }); this.nextGroupNumber++; this._createGroupOfSearchItems(pregroup); } /** * Add filters of type 'favorite' determined by the array this.favoriteFilters. */ _createGroupOfFavorites(irFilters) { let defaultFavoriteId = null; irFilters.forEach((irFilter) => { const favorite = this._irFilterToFavorite(irFilter); this._createGroupOfSearchItems([favorite]); if (favorite.isDefault) { defaultFavoriteId = favorite.id; } }); return defaultFavoriteId; } /** * Using a list (a 'pregroup') of 'prefilters', create new filters in `searchItems` * for each prefilter. The new filters belong to a same new group. */ _createGroupOfSearchItems(pregroup) { pregroup.forEach((preSearchItem) => { const searchItem = Object.assign(preSearchItem, { groupId: this.nextGroupId, id: this.nextId, }); this.searchItems[this.nextId] = searchItem; this.nextId++; }); this.nextGroupId++; } /** * Returns null or a copy of the provided filter with additional information * used only outside of the control panel model, like in search bar or in the * various menus. The value null is returned if the filter should not appear * for some reason. */ _enrichItem(searchItem) { if (searchItem.type === "field" && searchItem.fieldType === "properties") { return { ...searchItem }; } const queryElements = this.query.filter( (queryElem) => queryElem.searchItemId === searchItem.id ); const isActive = Boolean(queryElements.length); const enrichSearchItem = Object.assign({ isActive }, searchItem); function _enrichOptions(options, selectedIds) { return options.map((o) => { const { description, id, groupNumber } = o; const isActive = selectedIds.some((optionId) => optionId === id); return { description, id, groupNumber, isActive }; }); } switch (searchItem.type) { case "comparison": { const { dateFilterId } = searchItem; const dateFilterIsActive = this.query.some( (queryElem) => queryElem.searchItemId === dateFilterId && !queryElem.generatorId.startsWith("custom") ); if (!dateFilterIsActive) { return null; } break; } case "dateFilter": enrichSearchItem.options = _enrichOptions( getPeriodOptions(this.referenceMoment, searchItem.optionsParams), queryElements.map((queryElem) => queryElem.generatorId) ); break; case "dateGroupBy": enrichSearchItem.options = _enrichOptions( this.intervalOptions, queryElements.map((queryElem) => queryElem.intervalId) ); break; case "field": case "field_property": enrichSearchItem.autocompleteValues = queryElements.map( (queryElem) => queryElem.autocompleteValue ); break; } return enrichSearchItem; } /** * Ensures that the active value of a category is one of its own * existing values. * @param {Category} category * @param {number[]} valueIds */ _ensureCategoryValue(category, valueIds) { if (!valueIds.includes(category.activeValueId)) { category.activeValueId = valueIds[0]; } } _extractSearchDefaultsFromGlobalContext() { const searchDefaults = {}; const searchPanelDefaults = {}; for (const key in this.globalContext) { const defaultValue = this.globalContext[key]; const searchDefaultMatch = /^search_default_(.*)$/.exec(key); if (searchDefaultMatch) { if (defaultValue) { searchDefaults[searchDefaultMatch[1]] = defaultValue; } delete this.globalContext[key]; continue; } const searchPanelDefaultMatch = /^searchpanel_default_(.*)$/.exec(key); if (searchPanelDefaultMatch) { searchPanelDefaults[searchPanelDefaultMatch[1]] = defaultValue; delete this.globalContext[key]; } } return { searchDefaults, searchPanelDefaults }; } /** * Fetches values for each category at startup. At reload a category is * only fetched if needed. * @param {Category[]} categories * @returns {Promise} resolved when all categories have been fetched */ async _fetchCategories(categories) { const filterDomain = this._getFilterDomain(); const searchDomain = this.searchDomain; await Promise.all( categories.map(async (category) => { const result = await this.orm.call( this.resModel, "search_panel_select_range", [category.fieldName], { category_domain: this._getCategoryDomain(category.id), context: this.globalContext, enable_counters: category.enableCounters, expand: category.expand, filter_domain: filterDomain, hierarchize: category.hierarchize, limit: category.limit, search_domain: searchDomain, } ); this._createCategoryTree(category.id, result); }) ); } /** * Fetches values for each filter. This is done at startup and at each * reload if needed. * @param {Filter[]} filters * @returns {Promise} resolved when all filters have been fetched */ async _fetchFilters(filters) { const evalContext = {}; for (const category of this.categories) { evalContext[category.fieldName] = category.activeValueId; } const categoryDomain = this._getCategoryDomain(); const searchDomain = this.searchDomain; await Promise.all( filters.map(async (filter) => { const result = await this.orm.call( this.resModel, "search_panel_select_multi_range", [filter.fieldName], { category_domain: categoryDomain, comodel_domain: new Domain(filter.domain).toList(evalContext), context: this.globalContext, enable_counters: filter.enableCounters, filter_domain: this._getFilterDomain(filter.id), expand: filter.expand, group_by: filter.groupBy || false, group_domain: this._getGroupDomain(filter), limit: filter.limit, search_domain: searchDomain, } ); this._createFilterTree(filter.id, result); }) ); } /** * Fetches values for the given categories and filters. * @param {Category[]} categoriesToLoad * @param {Filter[]} filtersToLoad * @returns {Promise} resolved when all categories have been fetched */ async _fetchSections(categoriesToLoad, filtersToLoad) { await this._fetchCategories(categoriesToLoad); await this._fetchFilters(filtersToLoad); this.searchPanelInfo.loaded = true; } _getActiveComparison() { for (const queryElem of this.query) { const searchItem = this.searchItems[queryElem.searchItemId]; if (searchItem.type === "comparison") { return searchItem; } } return null; } /** * Computes and returns the domain based on the current active * categories. If "excludedCategoryId" is provided, the category with * that id is not taken into account in the domain computation. * @param {string} [excludedCategoryId] * @returns {Array[]} */ _getCategoryDomain(excludedCategoryId) { const domain = []; for (const category of this.categories) { if (category.id === excludedCategoryId || !category.activeValueId) { continue; } const field = this.searchViewFields[category.fieldName]; const operator = field.type === "many2one" && category.parentField ? "child_of" : "="; domain.push([category.fieldName, operator, category.activeValueId]); } return domain; } /** * Construct a single context from the contexts of * filters of type 'filter', 'favorite', and 'field'. * @returns {Object} */ _getContext() { const groups = this._getGroups(); const contexts = [user.context]; for (const group of groups) { for (const activeItem of group.activeItems) { const context = this._getSearchItemContext(activeItem); if (context) { contexts.push(context); } } } let context; try { context = makeContext(contexts); return context; } catch (error) { throw new Error( _t("Failed to evaluate the context: %(context)s.\n%(error)s", { context, error: error.message, }) ); } } /** * Compute the string representation or the description of the current domain associated * with a date filter starting from its corresponding query elements. */ _getDateFilterDomain(dateFilter, generatorIds, key = "domain") { const dateFilterRange = constructDateDomain(this.referenceMoment, dateFilter, generatorIds); return dateFilterRange[key]; } /** * Returns which components are displayed in the current action. Components * are opt-out, meaning that they will be displayed as long as a falsy * value is not provided. With the search panel, the view type must also * match the given (or default) search panel view types if the search model * is instanciated in a view (this doesn't apply for any other action type). * @private * @param {Object} [display={}] * @returns {{ controlPanel: Object | false, searchPanel: boolean, banner: boolean }} */ _getDisplay(display = {}) { const { viewTypes } = this.searchPanelInfo; const { bannerRoute, viewType } = this.env.config; return { controlPanel: "controlPanel" in display ? display.controlPanel : {}, searchPanel: this.sections.size && (!viewType || viewTypes.includes(viewType)) && ("searchPanel" in display ? display.searchPanel : true), banner: Boolean(bannerRoute), }; } /** * Return a domain created by combinining appropriately (with an 'AND') the domains * coming from the active groups of type 'filter', 'dateFilter', 'favorite', and 'field'. * @param {Object} [params] * @param {boolean} [params.raw=false] * @param {boolean} [params.withSearchPanel=true] * @param {boolean} [params.withGlobal=true] * @returns {DomainListRepr | Domain} Domain instance if 'raw', else the evaluated list domain */ _getDomain(params = {}) { const withSearchPanel = "withSearchPanel" in params ? params.withSearchPanel : true; const withGlobal = "withGlobal" in params ? params.withGlobal : true; const groups = this._getGroups(); const domains = []; if (withGlobal) { domains.push(this.globalDomain); } for (const group of groups) { const groupActiveItemDomains = []; for (const activeItem of group.activeItems) { const domain = this._getSearchItemDomain(activeItem); if (domain) { groupActiveItemDomains.push(domain); } } const groupDomain = Domain.or(groupActiveItemDomains); domains.push(groupDomain); } // we need to manage (optional) facets, deactivateGroup, clearQuery,... if (this.display.searchPanel && withSearchPanel) { domains.push(this._getSearchPanelDomain()); } let domain; try { domain = Domain.and(domains); return params.raw ? domain : domain.toList(this.domainEvalContext); } catch (error) { throw new Error( _t("Failed to evaluate the domain: %(domain)s.\n%(error)s", { domain: domain.toString(), error: error.message, }) ); } } _getFacets() { const facets = []; const groups = this._getGroups(); for (const group of groups) { const groupActiveItemDomains = []; const values = []; let title; let type; for (const activeItem of group.activeItems) { const domain = this._getSearchItemDomain(activeItem, { withDateFilterDomain: true, }); if (domain) { groupActiveItemDomains.push(domain); } const searchItem = this.searchItems[activeItem.searchItemId]; switch (searchItem.type) { case "field_property": case "field": { type = "field"; title = searchItem.description; for (const autocompleteValue of activeItem.autocompletValues) { values.push(autocompleteValue.label); } break; } case "groupBy": { type = "groupBy"; values.push(searchItem.description); break; } case "dateGroupBy": { type = "groupBy"; for (const intervalId of activeItem.intervalIds) { const option = this.intervalOptions.find((o) => o.id === intervalId); values.push(`${searchItem.description}: ${option.description}`); } break; } case "dateFilter": { type = "filter"; const periodDescription = this._getDateFilterDomain( searchItem, activeItem.generatorIds, "description" ); values.push(`${searchItem.description}: ${periodDescription}`); break; } default: { type = searchItem.type; values.push(searchItem.description); } } } const facet = { groupId: group.id, type, values, separator: type === "groupBy" ? ">" : _t("or"), }; if (type === "field") { facet.title = title; } else { if (type === "groupBy" && this.orderByCount) { facet.icon = FACET_ICONS[this.orderByCount === "Asc" ? "groupByAsc" : "groupByDesc"]; } else { facet.icon = FACET_ICONS[type]; } facet.color = FACET_COLORS[type]; } if (groupActiveItemDomains.length) { facet.domain = Domain.or(groupActiveItemDomains).toString(); } facets.push(facet); } return facets; } /** * Return the domain resulting from the combination of the autocomplete values * of a search item of type 'field'. */ _getFieldDomain(field, autocompleteValues) { const domains = autocompleteValues.map(({ label, value, operator, enforceEqual }) => { let domain; if (field.filterDomain) { let filterDomain = field.filterDomain; if (enforceEqual) { filterDomain = field.filterDomain .replaceAll("'ilike'", "'='") .replaceAll('"ilike"', '"="'); } domain = new Domain(filterDomain).toList({ self: label.trim(), raw_value: value, }); } 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); }); return Domain.or(domains); } /** * Computes and returns the domain based on the current checked * filters. The values of a single filter are combined using a simple * rule: checked values within a same group are combined with an "OR" * operator (this is expressed as single condition using a list) and * groups are combined with an "AND" operator (expressed by * concatenation of conditions). * If a filter has no group, its checked values are implicitely * considered as forming a group (and grouped using an "OR"). * If excludedFilterId is provided, the filter with that id is not * taken into account in the domain computation. * @param {string} [excludedFilterId] * @returns {Array[]} */ _getFilterDomain(excludedFilterId) { const domain = []; function addCondition(fieldName, valueMap) { const ids = []; for (const [valueId, value] of valueMap) { if (value.checked) { ids.push(valueId); } } if (ids.length) { domain.push([fieldName, "in", ids]); } } for (const filter of this.filters) { if (filter.id === excludedFilterId) { continue; } const { fieldName, groups, values } = filter; if (groups) { for (const group of groups.values()) { addCondition(fieldName, group.values); } } else { addCondition(fieldName, values); } } return domain; } /** * Return the concatenation of groupBys comming from the active filters of * type 'favorite' and 'groupBy'. * The result respects the appropriate logic: the groupBys * coming from an active favorite (if any) come first, then come the * groupBys comming from the active filters of type 'groupBy' in the order * defined in this.query. If no groupBys are found, one tries to * find some grouBys in this.globalContext. */ _getGroupBy() { const groups = this._getGroups(); const groupBys = []; for (const group of groups) { for (const activeItem of group.activeItems) { const activeItemGroupBys = this._getSearchItemGroupBys(activeItem); if (activeItemGroupBys) { groupBys.push(...activeItemGroupBys); } } } const groupBy = groupBys.length ? groupBys : this.globalGroupBy.slice(); return typeof groupBy === "string" ? [groupBy] : groupBy; } /** * Returns a domain or an object of domains used to complement * the filter domains to accurately describe the constrains on * records when computing record counts associated to the filter * values (if a groupBy is provided). The idea is that the checked * values within a group should not impact the counts for the other * values in the same group. * @param {Filter} filter * @returns {Object | Array[] | null} */ _getGroupDomain(filter) { const { fieldName, groups, enableCounters } = filter; const { type: fieldType } = this.searchViewFields[fieldName]; if (!enableCounters || !groups) { return { many2one: [], many2many: {}, }[fieldType]; } let groupDomain = null; if (fieldType === "many2one") { for (const group of groups.values()) { const valueIds = []; let active = false; for (const [valueId, value] of group.values) { const { checked } = value; valueIds.push(valueId); if (checked) { active = true; } } if (active) { if (groupDomain) { groupDomain = [[0, "=", 1]]; break; } else { groupDomain = [[fieldName, "in", valueIds]]; } } } } else if (fieldType === "many2many") { const checkedValueIds = new Map(); groups.forEach(({ values }, groupId) => { values.forEach(({ checked }, valueId) => { if (checked) { if (!checkedValueIds.has(groupId)) { checkedValueIds.set(groupId, []); } checkedValueIds.get(groupId).push(valueId); } }); }); groupDomain = {}; for (const [gId, ids] of checkedValueIds.entries()) { for (const groupId of groups.keys()) { if (gId !== groupId) { const key = JSON.stringify(groupId); if (!groupDomain[key]) { groupDomain[key] = []; } groupDomain[key].push([fieldName, "in", ids]); } } } } return groupDomain; } /** * Reconstruct the (active) groups from the query elements. * @returns {Object[]} */ _getGroups() { const preGroups = []; for (const queryElem of this.query) { const { searchItemId } = queryElem; const { groupId } = this.searchItems[searchItemId]; let preGroup = preGroups.find((group) => group.id === groupId); if (!preGroup) { preGroup = { id: groupId, queryElements: [] }; preGroups.push(preGroup); } preGroup.queryElements.push(queryElem); } const groups = []; for (const preGroup of preGroups) { const { queryElements, id } = preGroup; const activeItems = []; for (const queryElem of queryElements) { const { searchItemId } = queryElem; let activeItem = activeItems.find(({ searchItemId: id }) => id === searchItemId); if ("generatorId" in queryElem) { if (!activeItem) { activeItem = { searchItemId, generatorIds: [] }; activeItems.push(activeItem); } activeItem.generatorIds.push(queryElem.generatorId); } else if ("intervalId" in queryElem) { if (!activeItem) { activeItem = { searchItemId, intervalIds: [] }; activeItems.push(activeItem); } activeItem.intervalIds.push(queryElem.intervalId); } else if ("autocompleteValue" in queryElem) { if (!activeItem) { activeItem = { searchItemId, autocompletValues: [] }; activeItems.push(activeItem); } activeItem.autocompletValues.push(queryElem.autocompleteValue); } else { if (!activeItem) { activeItem = { searchItemId }; activeItems.push(activeItem); } } } for (const activeItem of activeItems) { if ("intervalIds" in activeItem) { activeItem.intervalIds.sort((g1, g2) => rankInterval(g1) - rankInterval(g2)); } } groups.push({ id, activeItems }); } return groups; } /** * * @private * @param {Object} [params={}] * @returns {{ preFavorite: Object, irFilter: Object }} */ _getIrFilterDescription(params = {}) { const { description, isDefault, isShared, embeddedActionId } = params; const fns = this.env.__getContext__.callbacks; const localContext = Object.assign({}, ...fns.map((fn) => fn())); const gs = this.env.__getOrderBy__.callbacks; let localOrderBy; if (gs.length) { localOrderBy = gs.flatMap((g) => g()); } const context = makeContext([this._getContext(), localContext]); const userContext = user.context; for (const key in context) { if (key in userContext || /^search(panel)?_default_/.test(key)) { // clean search defaults and user context keys delete context[key]; } } const domain = this._getDomain({ raw: true, withGlobal: false }).toString(); const groupBys = this._getGroupBy(); const comparison = this.getFullComparison(); const orderBy = localOrderBy || this._getOrderBy(); const userId = isShared ? false : user.userId; const preFavorite = { description, isDefault, domain, context, groupBys, orderBy, userId, }; const irFilter = { name: description, action_id: this.env.config.actionId, model_id: this.resModel, domain, embedded_action_id: embeddedActionId, embedded_parent_res_id: this.globalContext.active_id || false, is_default: isDefault, sort: JSON.stringify(orderBy.map((o) => `${o.name}${o.asc === false ? " desc" : ""}`)), user_id: userId, context: { group_by: groupBys, ...context }, }; if (comparison) { preFavorite.comparison = comparison; irFilter.context.comparison = comparison; } return { preFavorite, irFilter }; } /** * @returns {OrderTerm[]} */ _getOrderBy() { const groups = this._getGroups(); const orderBy = []; if (this.groupBy.length && this.orderByCount) { orderBy.push({ name: "__count", asc: this.orderByCount === "Asc" }); } for (const group of groups) { for (const activeItem of group.activeItems) { const { searchItemId } = activeItem; const searchItem = this.searchItems[searchItemId]; if (searchItem.type === "favorite") { orderBy.push(...searchItem.orderBy); } } } return orderBy.length ? orderBy : this.globalOrderBy; } /** * Return the context of the provided (active) filter. */ _getSearchItemContext(activeItem) { const { searchItemId } = activeItem; const searchItem = this.searchItems[searchItemId]; switch (searchItem.type) { case "field": { // for nodes, a dynamic context (like context="{'field1': self}") // should set {'field1': [value1, value2]} in the context let context = {}; if (searchItem.context) { try { const self = activeItem.autocompletValues.map( (autocompleValue) => autocompleValue.value ); context = evaluateExpr(searchItem.context, { self }); if (typeof context !== "object") { throw Error(); } } catch (error) { throw new Error( _t("Failed to evaluate the context: %(context)s.\n%(error)s", { context: searchItem.context, error: error.message, }) ); } } // the following code aims to remodel this: // https://github.com/odoo/odoo/blob/12.0/addons/web/static/src/js/views/search/search_inputs.js#L498 // this is required for the helpdesk tour to pass // this seems weird to only do that for m2o fields, but a test fails if // we do it for other fields (my guess being that the test should simply // be adapted) if (searchItem.isDefault && searchItem.fieldType === "many2one") { context[`default_${searchItem.fieldName}`] = searchItem.defaultAutocompleteValue.value; } return context; } case "favorite": case "filter": { //Return a deep copy of the filter/favorite to avoid the view to modify the context return makeContext([searchItem.context && deepCopy(searchItem.context)]); } default: { return null; } } } /** * Return the domain of the provided filter. * @param {Object} [options={}] * @param {boolean} [options.withDateFilterDomain] */ _getSearchItemDomain(activeItem, options = {}) { const { searchItemId } = activeItem; const searchItem = this.searchItems[searchItemId]; switch (searchItem.type) { case "field_property": case "field": { return this._getFieldDomain(searchItem, activeItem.autocompletValues); } case "dateFilter": { const { dateFilterId } = this._getActiveComparison() || {}; if ( options.withDateFilterDomain || !(this.searchMenuTypes.has("comparison") && dateFilterId === searchItemId) ) { return this._getDateFilterDomain(searchItem, activeItem.generatorIds); } return new Domain([]); } case "filter": case "favorite": { return searchItem.domain; } default: { return null; } } } _getSearchItemGroupBys(activeItem) { const { searchItemId } = activeItem; const searchItem = this.searchItems[searchItemId]; switch (searchItem.type) { case "dateGroupBy": { const { fieldName } = searchItem; return activeItem.intervalIds.map((intervalId) => `${fieldName}:${intervalId}`); } case "groupBy": { return [searchItem.fieldName]; } case "favorite": { return searchItem.groupBys; } default: { return null; } } } /** * Starting from a date filter id, returns the array of option ids currently selected * for the corresponding date filter. */ _getSelectedGeneratorIds(dateFilterId) { const selectedOptionIds = []; for (const queryElem of this.query) { if (queryElem.searchItemId === dateFilterId && "generatorId" in queryElem) { selectedOptionIds.push(queryElem.generatorId); } } return selectedOptionIds; } /** * @returns {Domain} */ _getSearchPanelDomain() { return Domain.and([this._getCategoryDomain(), this._getFilterDomain()]); } /** * @param {Object} state */ _importState(state) { execute(arraytoMap, state, this); } /** * @param {Object} irFilter */ _irFilterToFavorite(irFilter) { let userId = false; if (Array.isArray(irFilter.user_id)) { userId = irFilter.user_id[0]; } const groupNumber = userId ? FAVORITE_PRIVATE_GROUP : FAVORITE_SHARED_GROUP; const context = evaluateExpr(irFilter.context, user.context); let groupBys = []; if (context.group_by) { groupBys = context.group_by; delete context.group_by; } let comparison; if (context.comparison) { comparison = context.comparison; if (typeof comparison.range === "string") { // legacy case comparison.range = new Domain(comparison.range).toList(); } if (typeof comparison.comparisonRange === "string") { // legacy case comparison.comparisonRange = new Domain(comparison.comparisonRange).toList(); } delete context.comparison; } let sort; try { sort = JSON.parse(irFilter.sort); } catch (err) { if (err instanceof SyntaxError) { sort = []; } else { throw err; } } const orderBy = sort.map((order) => { let fieldName; let asc; const sqlNotation = order.split(" "); if (sqlNotation.length > 1) { // regex: \fieldName (asc|desc)?\ fieldName = sqlNotation[0]; asc = sqlNotation[1] === "asc"; } else { // legacy notation -- regex: \-?fieldName\ fieldName = order[0] === "-" ? order.slice(1) : order; asc = order[0] === "-" ? false : true; } return { asc: asc, name: fieldName, }; }); const favorite = { context, description: irFilter.name, domain: irFilter.domain, groupBys, groupNumber, orderBy, removable: true, serverSideId: irFilter.id, type: "favorite", userId, }; if (irFilter.is_default) { favorite.isDefault = irFilter.is_default; } if (comparison) { favorite.comparison = comparison; } return favorite; } async _notify() { if (this.blockNotification) { return; } this._reset(); await this._reloadSections(); this.trigger("update"); } /** * Updates the search domain and reloads sections if: * - the current search domain is different from the previous, or... * - a `shouldReload` flag has been set to true on the searchPanelInfo. * The latter means that the search domain has been modified while the * search panel was not displayed (and thus not reloaded) and the reload * should occur as soon as the search panel is visible again. * @private * @returns {Promise} */ async _reloadSections() { this.blockNotification = true; // Check whether the search domain changed const searchDomain = this._getDomain({ withSearchPanel: false }); const searchDomainChanged = this.searchPanelInfo.shouldReload || JSON.stringify(this.searchDomain) !== JSON.stringify(searchDomain); this.searchDomain = searchDomain; // Check whether categories/filters will force a reload of the sections const toFetch = (section) => section.enableCounters || (searchDomainChanged && !section.expand); const categoriesToFetch = this.categories.filter(toFetch); const filtersToFetch = this.filters.filter(toFetch); if (searchDomainChanged || Boolean(categoriesToFetch.length + filtersToFetch.length)) { if (this.display.searchPanel) { this.sectionsPromise = this._fetchSections(categoriesToFetch, filtersToFetch); if (this._shouldWaitForData(searchDomainChanged)) { await this.sectionsPromise; } } // If no current search panel: will try to reload on next model update this.searchPanelInfo.shouldReload = !this.display.searchPanel; } this.blockNotification = false; } _reset() { delete this._comparison; this._context = null; this._domain = null; this._groupBy = null; this._orderBy = null; } /** * Returns whether the query informations should be considered as ready * before or after having (re-)fetched the sections data. * @param {boolean} searchDomainChanged * @returns {boolean} */ _shouldWaitForData(searchDomainChanged) { if (this.categories.length && this.filters.some((filter) => filter.domain !== "[]")) { // Selected category value might affect the filter values return true; } if (!this.searchDomain.length) { // No search domain -> no need to check for expand return false; } return [...this.sections.values()].some( (section) => !section.expand && searchDomainChanged ); } /** * Legacy compatibility: the imported state of a legacy search panel model * extension doesn't include the arch information, i.e. the class name and * view types. We have to extract those if they are not given. * @param {Object} searchViewDescription * @param {Object} searchViewFields */ __legacyParseSearchPanelArchAnyway(searchViewDescription, searchViewFields) { if (this.searchPanelInfo) { return; } const parser = new SearchArchParser(searchViewDescription, searchViewFields); const { searchPanelInfo } = parser.parse(); this.searchPanelInfo = { ...searchPanelInfo, loaded: false, shouldReload: false }; } }