2459 lines
88 KiB
JavaScript
2459 lines
88 KiB
JavaScript
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<any,any>;
|
|
// }
|
|
|
|
// export interface Category extends SectionCommon {
|
|
// type: "category";
|
|
// hierarchize: boolean;
|
|
// }
|
|
|
|
// export interface Filter extends SectionCommon {
|
|
// type: "filter";
|
|
// domain: string;
|
|
// groupBy: string;
|
|
// groups: Map<any,any>;
|
|
// }
|
|
|
|
// 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<any, Object>} 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<any, Object>} 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="<search/>"]
|
|
* @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 || "<search/>";
|
|
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<number,Section> */
|
|
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 "<properties field name>.<property name>"
|
|
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: <id of the parent record>
|
|
* definitionRecordName: <display name of the parent record>
|
|
* definitions: <list of properties 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<string, Array[]> | 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 <field> 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<void>}
|
|
*/
|
|
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 };
|
|
}
|
|
}
|