diff --git a/addons/web/static/src/js/control_panel/control_panel_model.js b/addons/web/static/src/js/control_panel/control_panel_model_extension.js
similarity index 84%
rename from addons/web/static/src/js/control_panel/control_panel_model.js
rename to addons/web/static/src/js/control_panel/control_panel_model_extension.js
index d438e5175cb..ef443fbb70a 100644
--- a/addons/web/static/src/js/control_panel/control_panel_model.js
+++ b/addons/web/static/src/js/control_panel/control_panel_model_extension.js
@@ -1,17 +1,17 @@
-odoo.define('web.ControlPanelModel', function (require) {
+odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", function (require) {
"use strict";
+ const ActionModel = require("web/static/src/js/views/action_model.js");
const Domain = require('web.Domain');
- const { Model } = require('web.model');
- const { parseArch } = require('web.viewUtils');
const pyUtils = require('web.py_utils');
- const { DEFAULT_INTERVAL, DEFAULT_PERIOD, FACET_ICONS,
+ const { DEFAULT_INTERVAL, DEFAULT_PERIOD,
getComparisonOptions, getIntervalOptions, getPeriodOptions,
constructDateDomain, rankInterval, yearSelected } = require('web.searchUtils');
const FAVORITE_PRIVATE_GROUP = 1;
const FAVORITE_SHARED_GROUP = 2;
+ const DISABLE_FAVORITE = "search_disable_custom_filters";
let filterId = 1;
let groupId = 1;
@@ -140,54 +140,100 @@ odoo.define('web.ControlPanelModel', function (require) {
* The query elements indicates what are the active filters and 'how' they are active.
* The key groupId has been added for simplicity. It could have been removed from query elements
* since the information is available on the corresponding filters.
- * @extends Model
+ * @extends ActionModel.Extension
*/
- class ControlPanelModel extends Model {
+ class ControlPanelModelExtension extends ActionModel.Extension {
/**
* @param {Object} config
* @param {(string|number)} config.actionId
* @param {Object} config.env
* @param {string} config.modelName
- * @param {Object} [config.importedState]
- * @param {Array[]} [config.actionDomain=[]]
- * @param {Object} [config.actionContext={}]
+ * @param {Object} [config.context={}]
+ * @param {Object[]} [config.archNodes=[]]
* @param {Object[]} [config.dynamicFilters=[]]
* @param {string[]} [config.searchMenuTypes=[]]
- * @param {Object} [config.viewInfo={}]
+ * @param {Object} [config.favoriteFilters={}]
+ * @param {Object} [config.fields={}]
* @param {boolean} [config.withSearchBar=true]
- *
*/
- constructor(config) {
- super();
+ constructor() {
+ super(...arguments);
- this._setProperties(config);
-
- if (this.withSearchBar) {
- if (config.importedState) {
- this.importState(config.importedState);
- } else {
- this._prepareInitialState();
+ this.actionContext = Object.assign({}, this.config.context);
+ this.searchMenuTypes = this.config.searchMenuTypes || [];
+ this.favoriteFilters = this.config.favoriteFilters || [];
+ this.fields = this.config.fields || {};
+ this.searchDefaults = [];
+ for (const key in this.actionContext) {
+ const match = /^search_default_(.*)$/.exec(key);
+ if (match) {
+ const val = this.actionContext[key];
+ if (val) {
+ this.searchDefaults[match[1]] = val;
+ }
+ delete this.actionContext[key];
}
}
+ this.labelPromises = [];
- this.isReady = Promise.all(this.labelPromises);
- }
-
- //---------------------------------------------------------------------
- // Getters
- //---------------------------------------------------------------------
-
- /**
- * @returns {(Object | undefined)}
- */
- get activeComparison() {
- return this.state.query.find(queryElem => queryElem.type === 'comparison');
+ this.referenceMoment = moment();
+ this.optionGenerators = getPeriodOptions(this.referenceMoment);
+ this.intervalOptions = getIntervalOptions();
+ this.comparisonOptions = getComparisonOptions();
}
//---------------------------------------------------------------------
// Public
//---------------------------------------------------------------------
+ /**
+ * @override
+ * @returns {any}
+ */
+ get(property, ...args) {
+ switch (property) {
+ case "context": return this.getContext();
+ case "domain": return this.getDomain();
+ case "facets": return this._getFacets();
+ case "filters": return this._getFilters(...args);
+ case "groupBy": return this.getGroupBy();
+ case "orderedBy": return this.getOrderedBy();
+ case "timeRanges": return this.getTimeRanges();
+ }
+ }
+
+ /**
+ * @override
+ */
+ async load() {
+ await Promise.all(this.labelPromises);
+ }
+
+ /**
+ * @override
+ */
+ prepareState() {
+ Object.assign(this.state, {
+ filters: {},
+ query: [],
+ });
+ if (this.config.withSearchBar !== false) {
+ this._addFilters();
+ this._activateDefaultFilters();
+ }
+ }
+
+ //---------------------------------------------------------------------
+ // Actions / Getters
+ //---------------------------------------------------------------------
+
+ /**
+ * @returns {Object | undefined}
+ */
+ get activeComparison() {
+ return this.state.query.find(queryElem => queryElem.type === 'comparison');
+ }
+
/**
* Activate a filter of type 'field' with given filterId with
* 'autocompleteValues' value, label, and operator.
@@ -321,117 +367,59 @@ odoo.define('web.ControlPanelModel', function (require) {
}
/**
- * Return the state of the control panel model (the filters and the
- * current query). This state can then be used in an other control panel
- * model (with same key modelName). See importedState.
* @returns {Object}
*/
- exportState() {
- return JSON.parse(JSON.stringify(this.state));
- }
-
- /**
- * Called by search bar to render the facets
- * @returns {Object[]}
- */
- getFacets() {
- const accept = type => {
- if (['groupBy', 'comparison'].includes(type) && !this.searchMenuTypes.includes(type)) {
- return false;
- }
- return true;
- };
-
+ getContext() {
const groups = this._getGroups();
- const facets = [];
- for (const group of groups) {
- const { activities, type, id } = group;
- if (accept(type)) {
- const facet = {
- groupId: id,
- separator: type === 'groupBy' ? ">" : this.env._t("or"),
- values: this._getFacetDescriptions(activities, type),
- };
- const icon = FACET_ICONS[type];
- if (icon) {
- facet.icon = icon;
- } else {
- facet.title = activities[0].filter.description;
- }
- facets.push(facet);
- }
- }
- return facets;
+ return this._getContext(groups);
}
/**
- * Return an array containing enriched copies of the filters of the provided type.
- * @param {string} type
- * @returns {Object[]}
+ * @returns {Array[]}
*/
- getFiltersOfType(type) {
- const filters = Object.values(this.state.filters).reduce(
- (filters, filter) => {
- if (filter.type === type && !filter.invisible) {
- const filterQueryElements = this.state.query.filter(
- queryElem => queryElem.filterId === filter.id
- );
- const enrichedFilter = this._enrichFilterCopy(filter, filterQueryElements);
- if (enrichedFilter) {
- filters.push(enrichedFilter);
- }
- }
- return filters;
- },
- []
- );
- if (type === 'favorite') {
- filters.sort((f1, f2) => f1.groupNumber - f2.groupNumber);
+ getDomain() {
+ const groups = this._getGroups();
+ const userContext = this.env.session.user_context;
+ try {
+ return Domain.prototype.stringToArray(this._getDomain(groups), userContext);
+ } catch (err) {
+ throw new Error(
+ `${this.env._t("Control panel model extension failed to evaluate domain")}:/n${JSON.stringify(err)}`
+ );
}
- return filters;
}
/**
- * Principal objects used by controllers/models to fetch data.
- * @returns {Object} An object called search query with keys domain, groupBy,
- * context, orderedBy, and (optionally) timeRanges.
+ * @returns {string[]}
*/
- getQuery() {
+ getGroupBy() {
+ const groups = this._getGroups();
+ return this._getGroupBy(groups);
+ }
+
+ /**
+ * @returns {string[]}
+ */
+ getOrderedBy() {
+ const groups = this._getGroups();
+ return this._getOrderedBy(groups);
+ }
+
+ /**
+ * @returns {Object}
+ */
+ getTimeRanges() {
const requireEvaluation = true;
- const groups = this._getGroups();
- const query = {
- context: this._getContext(groups),
- domain: this._getDomain(groups, requireEvaluation),
- orderedBy: this._getOrderedBy(groups)
- };
- if (this.searchMenuTypes.includes('groupBy')) {
- const groupBy = this._getGroupBy(groups);
- query.groupBy = groupBy;
- } else {
- query.groupBy = [];
- }
- if (this.searchMenuTypes.includes('comparison')) {
- const timeRanges = this._getTimeRanges(requireEvaluation);
- query.timeRanges = timeRanges || {};
- }
- return query;
+ return this._getTimeRanges(requireEvaluation);
}
/**
- * Allow to reuse the state of a previous control panel model with same modelName.
- * This is mainly used when switching views.
- * @param {Object} state
+ * Used to call dispatch and trigger a 'search'.
*/
- importState(state) {
- Object.assign(this.state, state);
+ search() {
+ /* ... */
}
- /**
- * This function won't do anything: its purpose is to call the dispatch
- * method to trigger a 'search' event + reload the components.
- */
- search() { }
-
/**
* Activate/Deactivate a filter of type 'comparison' with provided id.
* At most one filter of type 'comparison' can be activated at every time.
@@ -514,27 +502,6 @@ odoo.define('web.ControlPanelModel', function (require) {
}
}
- /**
- * TODO: the way it is done could be improved, but the actual state of the
- * searchView doesn't allow to do much better.
- *
- * Update the domain of the search view by adding and/or removing filters.
- * @param {Object[]} newFilters list of filters to add, described by
- * objects with keys domain (the domain as an Array), description (the text
- * to display in the facet) and type with value 'filter'.
- * @param {number[]} filtersToRemove list of filter ids to remove
- * (previously added ones)
- * @returns {number[]} list of added filter ids (to pass as filtersToRemove
- * for a further call to this function)
- */
- updateFilters(newFilters, filtersToRemove) {
- const newFilterIdS = this.createNewFilters(newFilters);
- this.state.query = this.state.query.filter(
- queryElem => !filtersToRemove.includes(queryElem.filterId)
- );
- return newFilterIdS;
- }
-
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
@@ -573,9 +540,10 @@ odoo.define('web.ControlPanelModel', function (require) {
/**
* This function populates the 'filters' object at initialization.
* The filters come from:
- * - config.viewInfo.arch (types 'comparison', 'filter', 'groupBy', 'field'),
+ * - config.archNodes (types 'comparison', 'filter', 'groupBy', 'field'),
* - config.dynamicFilters (type 'filter'),
- * - config.viewInfo.favoriteFilters (type 'favorite'),
+ * - config.favoriteFilters (type 'favorite'),
+ * - code itself (type 'timeRange')
* @private
*/
_addFilters() {
@@ -610,12 +578,12 @@ odoo.define('web.ControlPanelModel', function (require) {
* Returns the active comparison timeRanges object.
* @private
* @param {Object} comparisonFilter
- * @returns {Object | undefined}
+ * @returns {Object | null}
*/
_computeTimeRanges(comparisonFilter) {
const { filterId } = this.activeComparison;
if (filterId !== comparisonFilter.id) {
- return;
+ return null;
}
const { dateFilterId, comparisonOptionId } = comparisonFilter;
const {
@@ -674,11 +642,12 @@ odoo.define('web.ControlPanelModel', function (require) {
}
/**
- * Add filters of type 'filter' determined by the key array this.dynamicFilters.
+ * Add filters of type 'filter' determined by the key array dynamicFilters.
* @private
*/
_createGroupOfDynamicFilters() {
- const pregroup = this.dynamicFilters.map(filter => {
+ const dynamicFilters = this.config.dynamicFilters || [];
+ const pregroup = dynamicFilters.map(filter => {
return {
description: filter.description,
domain: JSON.stringify(filter.domain),
@@ -690,14 +659,17 @@ odoo.define('web.ControlPanelModel', function (require) {
}
/**
- * Add filters of type 'favorite' determined by array this.favoriteFilters.
+ * Add filters of type 'favorite' determined by the array this.favoriteFilters.
* @private
*/
_createGroupOfFavorites() {
+ const activateFavorite = DISABLE_FAVORITE in this.actionContext ?
+ !this.actionContext[DISABLE_FAVORITE] :
+ true;
this.favoriteFilters.forEach(irFilter => {
const favorite = this._irFilterToFavorite(irFilter);
this._createGroupOfFilters([favorite]);
- if (this.activateDefaultFavorite && favorite.isDefault) {
+ if (activateFavorite && favorite.isDefault) {
this.defaultFavoriteId = favorite.id;
}
});
@@ -733,10 +705,7 @@ odoo.define('web.ControlPanelModel', function (require) {
* @private
*/
_createGroupOfFiltersFromArch() {
- const children = this.parsedArch.children.filter(
- child => child instanceof Object && child.tag !== 'searchpanel'
- );
- const preFilters = children.reduce(
+ const preFilters = this.config.archNodes.reduce(
(preFilters, child) => {
if (child.tag === 'group') {
return [...preFilters, ...child.children.map(c => this._evalArchChild(c))];
@@ -778,22 +747,6 @@ odoo.define('web.ControlPanelModel', function (require) {
// and others are passive (require input(s) to become determined)
// What is the right place to process the attrs?
};
- if (preFilter.attrs && JSON.parse(preFilter.attrs.modifiers || '{}').invisible) {
- filter.invisible = true;
-
- var preFilterFieldName = null;
- if (preFilter.tag == 'filter' && preFilter.attrs.date) {
- preFilterFieldName = preFilter.attrs.date;
- } else if (preFilter.tag == 'groupBy') {
- preFilterFieldName = preFilter.attrs.fieldName;
- }
- if (preFilterFieldName && !this.fields[preFilterFieldName]) {
- // In some case when a field is limited to specific groups
- // on the model, we need to ensure to discard related filter
- // as it may still be present in the view (in 'invisible' state)
- return;
- }
- }
if (filter.type === 'filter' || filter.type === 'groupBy') {
filter.groupNumber = groupNumber;
}
@@ -806,7 +759,7 @@ odoo.define('web.ControlPanelModel', function (require) {
this._createGroupOfFilters(pregroupOfGroupBys);
}
const dateFilters = Object.values(this.state.filters).filter(
- (filter) => filter.isDateFilter
+ (filter) => filter.isDateFilter
);
if (dateFilters.length) {
this._createGroupOfComparisons(dateFilters);
@@ -814,26 +767,14 @@ odoo.define('web.ControlPanelModel', function (require) {
}
/**
- * The parent of the control panel (an action or a controller) is not
- * necessarily a component. So we need to notify it through a
- * mechanism different from __notifyComponents. Here we use the fact
- * that the controlPanelModel is an (owl) EventBus to communicate with
- * the parent.
- * @private
- */
- _dispatch() {
- this.trigger('search', this.getQuery());
- }
-
- /**
- * Returns undefined or a copy of the provided filter with additional information
+ * 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 undefined is returned if the filter should not appear
+ * various menus. The value null is returned if the filter should not appear
* for some reason.
* @private
* @param {Object} filter
* @param {Object[]} filterQueryElements
- * @returns {Object|undefined}
+ * @returns {Object | null}
*/
_enrichFilterCopy(filter, filterQueryElements) {
const isActive = Boolean(filterQueryElements.length);
@@ -854,7 +795,7 @@ odoo.define('web.ControlPanelModel', function (require) {
queryElem => queryElem.filterId === dateFilterId
);
if (!dateFilterIsActive) {
- return;
+ return null;
}
break;
}
@@ -924,6 +865,9 @@ odoo.define('web.ControlPanelModel', function (require) {
filter.isDefault = attrs.isDefault;
}
filter.description = attrs.string || attrs.help || attrs.name || attrs.domain || 'Ω';
+ if (JSON.parse(attrs.modifiers || '{}').invisible) {
+ filter.invisible = true;
+ }
switch (filter.type) {
case 'filter':
if (attrs.context) {
@@ -972,7 +916,7 @@ odoo.define('web.ControlPanelModel', function (require) {
filter.defaultRank = -10;
filter.defaultAutocompleteValue = attrs.defaultAutocompleteValue;
}
- if (attrs.widget) { // FIXME: drop support in master
+ if (attrs.widget) {
filter.widget = attrs.widget;
}
break;
@@ -993,8 +937,8 @@ odoo.define('web.ControlPanelModel', function (require) {
*/
_favoriteToIrFilter(favorite) {
const irFilter = {
- action_id: this.actionId,
- model_id: this.modelName,
+ action_id: this.config.actionId,
+ model_id: this.config.modelName,
};
// ir.filter fields
@@ -1069,7 +1013,7 @@ odoo.define('web.ControlPanelModel', function (require) {
* @private
* @returns {Object}
*/
- _getContext(groups, withActionContext = true) {
+ _getContext(groups) {
const types = ['filter', 'favorite', 'field'];
const contexts = groups.reduce(
(contexts, group) => {
@@ -1080,9 +1024,6 @@ odoo.define('web.ControlPanelModel', function (require) {
},
[]
);
- if (withActionContext) {
- contexts.unshift(this.actionContext);
- }
const evaluationContext = this.env.session.user_context;
try {
return pyUtils.eval('contexts', contexts, evaluationContext);
@@ -1118,35 +1059,97 @@ odoo.define('web.ControlPanelModel', function (require) {
* of type 'filter', 'favorite', and 'field'.
* @private
* @param {Object[]} groups
- * @param {boolean} [evaluation=true]
* @returns {string}
*/
- _getDomain(groups, evaluation = true) {
+ _getDomain(groups) {
const types = ['filter', 'favorite', 'field'];
- const domains = groups.reduce(
- (domains, group) => {
- if (types.includes(group.type)) {
- domains.push(this._getGroupDomain(group));
- }
- return domains;
- },
- []
- );
- let filterDomain = pyUtils.assembleDomains(domains, 'AND');
+ const domains = [];
+ for (const group of groups) {
+ if (types.includes(group.type)) {
+ domains.push(this._getGroupDomain(group));
+ }
+ }
+ return pyUtils.assembleDomains(domains, 'AND');
+ }
- if (evaluation) {
- const userContext = this.env.session.user_context;
- try {
- return pyUtils.eval('domains', [this.actionDomain, filterDomain], userContext);
- } catch (err) {
- throw new Error(
- this.env._t("Failed to evaluate search domain") + ":\n" +
- JSON.stringify(err)
- );
+ /**
+ * Get the filter description to use in the search bar as a facet.
+ * @private
+ * @param {Object} activity
+ * @param {Object} activity.filter
+ * @param {Object[]} activity.filterQueryElements
+ * @returns {string}
+ */
+ _getFacetDescriptions(activities, type) {
+ const facetDescriptions = [];
+ if (type === 'field') {
+ for (const queryElem of activities[0].filterQueryElements) {
+ facetDescriptions.push(queryElem.label);
+ }
+ } else if (type === 'groupBy') {
+ for (const { filter, filterQueryElements } of activities) {
+ if (filter.hasOptions) {
+ for (const queryElem of filterQueryElements) {
+ const option = this.intervalOptions.find(
+ o => o.id === queryElem.optionId
+ );
+ facetDescriptions.push(filter.description + ': ' + option.description);
+ }
+ } else {
+ facetDescriptions.push(filter.description);
+ }
}
} else {
- return filterDomain;
+ let facetDescription;
+ for (const { filter, filterQueryElements } of activities) {
+ // filter, favorite and comparison
+ facetDescription = filter.description;
+ if (filter.isDateFilter) {
+ const description = this._getDateFilterDomain(
+ filter, filterQueryElements, 'description'
+ );
+ facetDescription += `: ${description}`;
+ }
+ facetDescriptions.push(facetDescription);
+ }
}
+ return facetDescriptions;
+ }
+
+ /**
+ * @returns {Object[]}
+ */
+ _getFacets() {
+ const facets = this._getGroups().map(({ activities, type, id }) => {
+ const values = this._getFacetDescriptions(activities, type);
+ const title = activities[0].filter.description;
+ return { groupId: id, title, type, values };
+ });
+ return facets;
+ }
+
+ /**
+ * Return an array containing enriched copies of the filters of the provided type.
+ * @param {Function} predicate
+ * @returns {Object[]}
+ */
+ _getFilters(predicate) {
+ const filters = [];
+ Object.values(this.state.filters).forEach(filter => {
+ if ((!predicate || predicate(filter)) && !filter.invisible) {
+ const filterQueryElements = this.state.query.filter(
+ queryElem => queryElem.filterId === filter.id
+ );
+ const enrichedFilter = this._enrichFilterCopy(filter, filterQueryElements);
+ if (enrichedFilter) {
+ filters.push(enrichedFilter);
+ }
+ }
+ });
+ if (filters.some(f => f.type === 'favorite')) {
+ filters.sort((f1, f2) => f1.groupNumber - f2.groupNumber);
+ }
+ return filters;
}
/**
@@ -1358,6 +1361,36 @@ odoo.define('web.ControlPanelModel', function (require) {
return selectedOptionIds;
}
+ /**
+ * Returns the last timeRanges object found in the query.
+ * TimeRanges objects can be associated with filters of type 'favorite'
+ * or 'comparison'.
+ * @private
+ * @param {boolean} [evaluation=false]
+ * @returns {Object | null}
+ */
+ _getTimeRanges(evaluation) {
+ let timeRanges;
+ for (const queryElem of this.state.query.slice().reverse()) {
+ const filter = this.state.filters[queryElem.filterId];
+ if (filter.type === 'comparison') {
+ timeRanges = this._computeTimeRanges(filter);
+ break;
+ } else if (filter.type === 'favorite' && filter.comparison) {
+ timeRanges = filter.comparison;
+ break;
+ }
+ }
+ if (timeRanges) {
+ if (evaluation) {
+ timeRanges.range = Domain.prototype.stringToArray(timeRanges.range);
+ timeRanges.comparisonRange = Domain.prototype.stringToArray(timeRanges.comparisonRange);
+ }
+ return timeRanges;
+ }
+ return null;
+ }
+
/**
* Returns a filter of type 'favorite' starting from an ir_filter comming from db.
* @private
@@ -1496,8 +1529,10 @@ odoo.define('web.ControlPanelModel', function (require) {
} else if (fieldType === 'many2one') {
const updateLabel = label => {
const queryElem = this.state.query.find(({ filterId }) => filterId === id);
- queryElem.label = label;
- defaultAutocompleteValue.label = label;
+ if (queryElem) {
+ queryElem.label = label;
+ defaultAutocompleteValue.label = label;
+ }
};
const promise = this.env.services.rpc({
args: [defaultAutocompleteValue.value],
@@ -1513,14 +1548,6 @@ odoo.define('web.ControlPanelModel', function (require) {
}
}
- /**
- * @private
- */
- _prepareInitialState() {
- this._addFilters();
- this._activateDefaultFilters();
- }
-
/**
* Compute the search Query and save it as an ir_filter in db.
* No evaluation of domains is done in order to keep them dynamic.
@@ -1535,17 +1562,13 @@ odoo.define('web.ControlPanelModel', function (require) {
const userContext = this.env.session.user_context;
let controllerQueryParams;
- this.trigger(
- 'get-controller-query-params',
- p => {
- controllerQueryParams = p;
- }
- );
+ this.config.trigger("get-controller-query-params", params => {
+ controllerQueryParams = params;
+ });
controllerQueryParams = controllerQueryParams || {};
controllerQueryParams.context = controllerQueryParams.context || {};
- const withoutActiveContext = false;
- const queryContext = this._getContext(groups, withoutActiveContext);
+ const queryContext = this._getContext(groups);
const context = pyUtils.eval(
'contexts',
[userContext, controllerQueryParams.context, queryContext]
@@ -1555,7 +1578,7 @@ odoo.define('web.ControlPanelModel', function (require) {
}
const requireEvaluation = false;
- const domain = this._getDomain(groups, requireEvaluation);
+ const domain = this._getDomain(groups);
const groupBys = this._getGroupBy(groups);
const timeRanges = this._getTimeRanges(requireEvaluation);
const orderedBy = controllerQueryParams.orderedBy ?
@@ -1585,128 +1608,30 @@ odoo.define('web.ControlPanelModel', function (require) {
return preFilter;
}
- /**
- * Get the filter description to use in the search bar as a facet.
- * @private
- * @param {Object} activity
- * @param {Object} activity.filter
- * @param {Object[]} activity.filterQueryElements
- * @returns {string}
- */
- _getFacetDescriptions(activities, type) {
- const facetDescriptions = [];
- if (type === 'field') {
- for (const queryElem of activities[0].filterQueryElements) {
- facetDescriptions.push(queryElem.label);
- }
- } else if (type === 'groupBy') {
- for (const { filter, filterQueryElements } of activities) {
- if (filter.hasOptions) {
- for (const queryElem of filterQueryElements) {
- const option = this.intervalOptions.find(
- o => o.id === queryElem.optionId
- );
- facetDescriptions.push(filter.description + ': ' + option.description);
- }
- } else {
- facetDescriptions.push(filter.description);
- }
- }
- } else {
- let facetDescription;
- for (const { filter, filterQueryElements } of activities) {
- // filter, favorite and comparison
- facetDescription = filter.description;
- if (filter.isDateFilter) {
- const description = this._getDateFilterDomain(
- filter, filterQueryElements, 'description'
- );
- facetDescription += `: ${description}`;
- }
- facetDescriptions.push(facetDescription);
- }
- }
- return facetDescriptions;
- }
+ //---------------------------------------------------------------------
+ // Static
+ //---------------------------------------------------------------------
/**
- * Returns the last timeRanges object found in the query.
- * TimeRanges objects can be associated with filters of type 'favorite'
- * or 'comparison'.
- * @private
- * @param {boolean} [evaluation=false]
- * @returns {(Object|undefined)}
+ * @override
+ * @returns {{ attrs: Object, children: Object[] }}
*/
- _getTimeRanges(evaluation) {
- let timeRanges;
- for (const queryElem of this.state.query.slice().reverse()) {
- const filter = this.state.filters[queryElem.filterId];
- if (filter.type === 'comparison') {
- timeRanges = this._computeTimeRanges(filter);
- break;
- } else if (filter.type === 'favorite' && filter.comparison) {
- timeRanges = filter.comparison;
- break;
- }
- }
- if (timeRanges) {
- if (evaluation) {
- timeRanges.range = Domain.prototype.stringToArray(timeRanges.range);
- timeRanges.comparisonRange = Domain.prototype.stringToArray(timeRanges.comparisonRange);
- }
- return timeRanges;
- }
- }
-
- /**
- * Using the constructor parameter object config, set most of the properties
- * of the control panel model.
- * @private
- * @param {Object} config
- */
- _setProperties(config) {
- this.state = {
- filters: {},
- query: [],
+ static extractArchInfo(archs) {
+ const { attrs, children } = archs.search;
+ const controlPanelInfo = {
+ attrs,
+ children: [],
};
- this.env = config.env;
- this.modelName = config.modelName;
- this.actionDomain = config.actionDomain || [];
- this.actionContext = config.actionContext || {};
- this.actionId = config.actionId;
- this.withSearchBar = 'withSearchBar' in config ? config.withSearchBar : true;
- this.searchMenuTypes = config.searchMenuTypes || [];
-
- this.searchDefaults = [];
- for (const key in this.actionContext) {
- const match = /^search_default_(.*)$/.exec(key);
- if (match) {
- const val = this.actionContext[key];
- if (val) {
- this.searchDefaults[match[1]] = val;
- }
- delete this.actionContext[key];
+ for (const child of children) {
+ if (child.tag !== "searchpanel") {
+ controlPanelInfo.children.push(child);
}
}
- this.labelPromises = [];
-
- const viewInfo = config.viewInfo || {};
-
- this.parsedArch = parseArch(viewInfo.arch || '');
- this.fields = viewInfo.fields || {};
- this.favoriteFilters = viewInfo.favoriteFilters || [];
- this.activateDefaultFavorite = 'search_disable_custom_filters' in this.actionContext ?
- !this.actionContext.search_disable_custom_filters :
- true;
-
- this.dynamicFilters = config.dynamicFilters || [];
-
- this.referenceMoment = moment();
- this.optionGenerators = getPeriodOptions(this.referenceMoment);
- this.intervalOptions = getIntervalOptions();
- this.comparisonOptions = getComparisonOptions();
+ return controlPanelInfo;
}
}
- return ControlPanelModel;
+ ActionModel.registry.add("ControlPanel", ControlPanelModelExtension, 10);
+
+ return ControlPanelModelExtension;
});
diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js
index 17ce0e080b2..a5a5f4eb465 100644
--- a/addons/web/static/src/js/core/utils.js
+++ b/addons/web/static/src/js/core/utils.js
@@ -571,6 +571,31 @@ var utils = {
str = "" + str;
return new Array(size - str.length + 1).join('0') + str;
},
+ /**
+ * @param {any[]} arr
+ * @param {Function} fn
+ * @returns {any[]}
+ */
+ partitionBy(arr, fn) {
+ let lastGroup = false;
+ let lastValue;
+ return arr.reduce((acc, cur) => {
+ let curVal = fn(cur);
+ if (lastGroup) {
+ if (curVal === lastValue) {
+ lastGroup.push(cur);
+ } else {
+ lastGroup = false;
+ }
+ }
+ if (!lastGroup) {
+ lastGroup = [cur];
+ acc.push(lastGroup);
+ }
+ lastValue = curVal;
+ return acc;
+ }, []);
+ },
/**
* Patch a class and return a function that remove the patch
* when called.
diff --git a/addons/web/static/src/js/model.js b/addons/web/static/src/js/model.js
index 8e3842e5175..b86599eb5fc 100644
--- a/addons/web/static/src/js/model.js
+++ b/addons/web/static/src/js/model.js
@@ -1,53 +1,27 @@
-odoo.define('web.model', function () {
+odoo.define("web/static/src/js/model.js", function (require) {
"use strict";
+ const { groupBy, partitionBy } = require("web.utils");
+ const Registry = require("web.Registry");
+
const { Component, core } = owl;
const { EventBus, Observer } = core;
-
- // TODO: remove this if made available in owl.utils
- function partitionBy(arr, fn) {
- let lastGroup = false;
- let lastValue;
- return arr.reduce((acc, cur) => {
- let curVal = fn(cur);
- if (lastGroup) {
- if (curVal === lastValue) {
- lastGroup.push(cur);
- }
- else {
- lastGroup = false;
- }
- }
- if (!lastGroup) {
- lastGroup = [cur];
- acc.push(lastGroup);
- }
- lastValue = curVal;
- return acc;
- }, []);
- }
+ const isNotNull = (val) => val !== null && val !== undefined;
/**
- * Model
- *
- * The purpose of the class Model and the associated hook useModel
- * is to offer something similar to an owl store but with no automatic
- * notification (and rendering) of components when the 'state' used in the model
- * would change. Instead, one should call the __notifyComponents function whenever
- * it is useful to alert registered component. Nevertheless,
- * when calling a method throught the dispatch method, a notifcation
- * does take place automatically, and registered components (via useModel) are rendered.
- *
- * it is highly expected that this class will change in a near future. We don't have
- * the necessary hindsight to be sure its actual form is good.
- * @extends EventBus
+ * Feature extension of the class Model.
+ * @see {Model}
*/
- class Model extends EventBus {
-
- constructor() {
- super();
- this.rev = 1;
- this.mapping = {}; // could be a weak map
+ class ModelExtension {
+ /**
+ * @param {Object} config
+ * @param {Object} config.env
+ */
+ constructor(config) {
+ this.config = config;
+ this.env = this.config.env;
+ this._shouldLoad = true;
+ this.state = {};
}
//---------------------------------------------------------------------
@@ -55,23 +29,362 @@ odoo.define('web.model', function () {
//---------------------------------------------------------------------
/**
- * Call the base model method with given name with the arguments
- * determined by the dispatch extra arguments.
- *
- * @param {string} action
- * @param {...any} args
- * @returns {Promise}
+ * Used by the parent model to initiate a load action. The actual
+ * loading of the extension is determined by the "_shouldLoad" property.
*/
- async dispatch(action, ...args) {
- const result = await this[action](...args);
- // TODO try to put it in promise.resolve()
- this._dispatch(...arguments);
- let rev = this.rev;
- await Promise.resolve();
- if (rev === this.rev) {
- await this._notifyComponents();
+ async callLoad() {
+ if (this._shouldLoad) {
+ this._shouldLoad = false;
+ await this.load();
+ }
+ }
+
+ /**
+ * Exports the current state of the extension.
+ * @returns {Object}
+ */
+ exportState() {
+ return this.state;
+ }
+
+ /**
+ * Meant to return the result of the appropriate getter or do nothing
+ * if not concerned by the given property.
+ * @abstract
+ * @param {string} property
+ * @param {...any} args
+ * @returns {null}
+ */
+ get() {
+ return null;
+ }
+
+ /**
+ * Imports the given state after parsing it. If no state is given the
+ * extension will prepare a new state and will need to be loaded.
+ * @param {Object} [state]
+ */
+ importState(state) {
+ this._shouldLoad = !state;
+ if (this._shouldLoad) {
+ this.prepareState();
+ } else {
+ Object.assign(this.state, state);
+ }
+ }
+
+ /**
+ * Called and awaited on initial model load.
+ * @abstract
+ * @returns {Promise}
+ */
+ async load() {
+ /* ... */
+ }
+
+ /**
+ * Called on initialization if no imported state for the extension is
+ * found.
+ * @abstract
+ */
+ prepareState() {
+ /* ... */
+ }
+
+ /**
+ * Called and awaited after an action dispatch.
+ * @abstract
+ * @returns {Promise}
+ */
+ async reloadAfterDispatch() {
+ /* ... */
+ }
+ }
+ /**
+ * The layer of an extension indicates with which other extensions this one
+ * will be loaded. This property must be overridden in case the model
+ * depends on other extensions to be loaded first.
+ */
+ ModelExtension.layer = 0;
+
+ /**
+ * Model
+ *
+ * The purpose of the class Model and the associated hook useModel
+ * is to offer something similar to an owl store but with no automatic
+ * notification (and rendering) of components when the 'state' used in the
+ * model would change. Instead, one should call the "__notifyComponents"
+ * function whenever it is useful to alert registered component.
+ * Nevertheless, when calling a method through the 'dispatch' method, a
+ * notification does take place automatically, and registered components
+ * (via useModel) are rendered.
+ *
+ * It is highly expected that this class will change in a near future. We
+ * don't have the necessary hindsight to be sure its actual form is good.
+ *
+ * The following snippets show a typical use case of the model system: a
+ * search model with a control panel extension feature.
+ *
+ *-------------------------------------------------------------------------
+ * MODEL AND EXTENSIONS DEFINITION
+ *-------------------------------------------------------------------------
+ *
+ * 1. Definition of the main model
+ * @see Model
+ * ```
+ * class ActionModel extends Model {
+ * // ...
+ * }
+ * ```
+ *
+ * 2. Definition of the model extension
+ * @see ModelExtension
+ * ```
+ * class ControlPanelModelExtension extends ActionModel.Extension {
+ * // ...
+ * }
+ * ```
+ *
+ * 3. Registration of the extension into the main model
+ * @see Registry()
+ * ```
+ * ActionModel.registry.add("SearchPanel", ControlPanelModelExtension, 10);
+ * ```
+ *
+ *-------------------------------------------------------------------------
+ * ON VIEW/ACTION INIT
+ *-------------------------------------------------------------------------
+ *
+ * 4. Creation of the core model and its extensions
+ * @see Model.prototype.constructor()
+ * ```
+ * const extensions = {
+ * SearchPanel: {
+ * // ...
+ * }
+ * }
+ * const searchModelConfig = {
+ * // ...
+ * };
+ * const actionModel = new ActionModel(extensions, searchModelConfig);
+ * ```
+ *
+ * 5. Loading of all extensions' asynchronous data
+ * @see Model.prototype.load()
+ * ```
+ * await actionModel.load();
+ * ```
+ *
+ * 6. Subscribing to the model changes
+ * @see useModel()
+ * ```
+ * class ControlPanel extends Component {
+ * constructor() {
+ * super(...arguments);
+ * // env must contain the actionModel
+ * this.actionModel = useModel('actionModel');
+ * }
+ * }
+ * ```
+ *
+ *-------------------------------------------------------------------------
+ * MODEL USAGE ON RUNTIME
+ *-------------------------------------------------------------------------
+ *
+ * Case: dispatch an action
+ * @see Model.prototype.dispatch()
+ * ```
+ * actionModel.dispatch("updateProperty", value);
+ * ```
+ *
+ * Case: call a getter
+ * @see Model.prototype.get()
+ * ```
+ * const result = actionModel.get("property");
+ * ```
+ *
+ * @abstract
+ * @extends EventBus
+ */
+ class Model extends EventBus {
+ /**
+ * Instantiated extensions are determined by the `extensions` argument:
+ * - keys are the extensions names as added in the registry
+ * - values are the local configurations given to each extension
+ * The extensions are grouped by the sequence number they where
+ * registered with in the registry. Extensions being on the same level
+ * will be loaded in parallel; this means that all extensions belonging
+ * to the same group are awaited before loading the next group.
+ * @param {Object} [extensions={}]
+ * @param {Object} [globalConfig={}] global configuration: can be
+ * accessed by itself and each of the added extensions.
+ * @param {Object} [globalConfig.env]
+ * @param {string} [globalConfig.importedState]
+ */
+ constructor(extensions = {}, globalConfig = {}) {
+ super();
+
+ this.config = globalConfig;
+ this.env = this.config.env;
+
+ this.dispatching = false;
+ this.extensions = [];
+ this.externalState = {};
+ this.mapping = {};
+ this.rev = 1;
+
+ const { name, registry } = this.constructor;
+ if (!registry || !(registry instanceof Registry)) {
+ throw new Error(`Unimplemented registry on model "${name}".`);
+ }
+ // Order, group and sequencially instantiate all extensions
+ const registryExtensions = Object.entries(registry.entries());
+ const extensionNameLayers = registryExtensions.map(
+ ([name, { layer }]) => ({ name, layer })
+ );
+ const groupedNameLayers = groupBy(extensionNameLayers, "layer");
+ for (const groupNameLayers of Object.values(groupedNameLayers)) {
+ for (const { name } of groupNameLayers) {
+ if (name in extensions) {
+ this.addExtension(name, extensions[name]);
+ }
+ }
+ }
+ this.importState(this.config.importedState);
+ }
+
+ //---------------------------------------------------------------------
+ // Public
+ //---------------------------------------------------------------------
+
+ /**
+ * Method used internally to instantiate all extensions. Can also be
+ * called externally to add extensions after model instantiation.
+ * @param {string} extensionName
+ * @param {Object} extensionConfig
+ */
+ addExtension(extensionName, extensionConfig) {
+ const { name, registry } = this.constructor;
+ const Extension = registry.get(extensionName);
+ if (!Extension) {
+ throw new Error(`Unknown model extension "${extensionName}" in model "${name}"`);
+ }
+ // Extension config = this.config ∪ extension.config
+ const get = this.__get.bind(this, Extension.name);
+ const trigger = this.trigger.bind(this);
+ const config = Object.assign({ get, trigger }, this.config, extensionConfig);
+ const extension = new Extension(config);
+ if (!(Extension.layer in this.extensions)) {
+ this.extensions[Extension.layer] = [];
+ }
+ this.extensions[Extension.layer].push(extension);
+ }
+
+ /**
+ * Returns the result of the first related method on any instantiated
+ * extension. This method must be overridden if multiple extensions
+ * return a value with a common method (and dispatchAll does not
+ * suffice). After the dispatch of the action, all models are partially
+ * reloaded (@see ModelExtension.reloadAfterDispatch()) and components
+ * are notified afterwards.
+ */
+ dispatch(method, ...args) {
+ const isInitialDispatch = !this.dispatching;
+ this.dispatching = true;
+ for (const extension of this.extensions.flat()) {
+ if (method in extension) {
+ extension[method](...args);
+ }
+ }
+ if (isInitialDispatch) {
+ this.dispatching = false;
+ (async () => {
+ // Calls 'after dispatch' hooks
+ // Purpose: fetch updated data from the server. This is
+ // considered a loading action and is thus performed by groups
+ // instead of loading all extensions at once.
+ for (let layer = 0; layer < this.extensions.length; layer++) {
+ const afterDispatchPromises = [];
+ for (const extension of this.extensions[layer]) {
+ afterDispatchPromises.push(extension.reloadAfterDispatch());
+ }
+ await Promise.all(afterDispatchPromises);
+ }
+ // Notifies subscribed components
+ // Purpose: re-render components bound by 'useModel'
+ let rev = this.rev;
+ await Promise.resolve();
+ if (rev === this.rev) {
+ this._notifyComponents();
+ }
+ })();
+ }
+ }
+
+ /**
+ * Stringifies and exports an object holding the exported state of each
+ * active extension.
+ * @returns {string}
+ */
+ exportState() {
+ const exported = {};
+ for (const extension of this.extensions.flat()) {
+ exported[extension.constructor.name] = extension.exportState();
+ }
+ const fullState = Object.assign({}, this.externalState, exported);
+ return JSON.stringify(fullState);
+ }
+
+ /**
+ * Returns the result of the first related getter on any instantiated
+ * extension. This method must be overridden if multiple extensions
+ * share a common getter (and getAll does not make the job).
+ * @param {string} property
+ * @param {...any} args
+ * @returns {any}
+ */
+ get(property, ...args) {
+ for (const extension of this.extensions.flat()) {
+ const result = extension.get(property, ...args);
+ if (isNotNull(result)) {
+ return result;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Parses the given stringified state object and imports each state
+ * part to its related extension.
+ * @param {string} [stringifiedState="null"]
+ */
+ importState(stringifiedState = "null") {
+ const state = JSON.parse(stringifiedState) || {};
+ Object.assign(this.externalState, state);
+ for (const extension of this.extensions.flat()) {
+ extension.importState(state[extension.constructor.name]);
+ }
+ }
+
+ /**
+ * Must be called after construction and state preparation/import.
+ * Waits for all asynchronous work needed by the model extensions to be
+ * ready.
+ * /!\ The current model extensions do not require a smarter system at
+ * the moment (therefore using layers instead of dependencies). It
+ * should be changed if at some point an extension needs another
+ * specific extension to be loaded instead of a whole batch (with the
+ * current system some promises will be waited needlessly).
+ * @returns {Promise}
+ */
+ async load() {
+ for (let layer = 0; layer < this.extensions.length; layer++) {
+ const layerPromises = [];
+ for (const extension of this.extensions[layer]) {
+ layerPromises.push(extension.callLoad());
+ }
+ await Promise.all(layerPromises);
}
- return result;
}
//---------------------------------------------------------------------
@@ -79,38 +392,66 @@ odoo.define('web.model', function () {
//---------------------------------------------------------------------
/**
+ * Returns the list of the results of all extensions providing a getter
+ * for the given property returning a non-null value, excluding the
+ * extension whose name is equal to "excluded". This method is given to
+ * each extension in the "config" object bound to the model scope and
+ * having the extension name bound as the first argument.
* @private
- * @param {string} action
- * @param {...any} args
+ * @param {string} excluded
+ * @param {string} property
+ * @param {...any} args
+ * @returns {any[]}
*/
- _dispatch(action, ...args) { }
+ __get(excluded, property, ...args) {
+ const results = [];
+ for (const extension of this.extensions.flat()) {
+ if (extension.constructor.name !== excluded) {
+ const result = extension.get(property, ...args);
+ if (isNotNull(result)) {
+ results.push(result);
+ }
+ }
+ }
+ return results;
+ }
/**
- * see Context method in owl.js for explanation
+ * @see Context.__notifyComponents() in owl.js for explanation
* @private
*/
async _notifyComponents() {
const rev = ++this.rev;
const subscriptions = this.subscriptions.update;
- const groups = partitionBy(subscriptions, s => (s.owner ? s.owner.__owl__.depth : -1));
+ const groups = partitionBy(subscriptions, (s) =>
+ s.owner ? s.owner.__owl__.depth : -1
+ );
for (let group of groups) {
- const proms = group.map(sub => sub.callback.call(sub.owner, rev));
+ const proms = group.map((sub) =>
+ sub.callback.call(sub.owner, rev)
+ );
Component.scheduler.flush();
await Promise.all(proms);
}
}
}
+ Model.Extension = ModelExtension;
+
/**
- * This is more or less the hook 'useContextWithCB' from owl only slightly simplified.
+ * This is more or less the hook 'useContextWithCB' from owl only slightly
+ * simplified.
*
* @param {string} modelName
+ * @returns {model}
*/
function useModel(modelName) {
const component = Component.current;
const model = component.env[modelName];
if (!(model instanceof Model)) {
- throw new Error(`No Model found when connecting '${component.constructor.name}'`);
+ throw new Error(`No Model found when connecting '${
+ component.name
+ }'`);
}
const mapping = model.mapping;
@@ -134,7 +475,7 @@ odoo.define('web.model', function () {
return renderFn(comp, params);
};
- model.on('update', component, async modelRev => {
+ model.on("update", component, async (modelRev) => {
if (mapping[componentId] < modelRev) {
mapping[componentId] = modelRev;
await component.render();
@@ -142,7 +483,7 @@ odoo.define('web.model', function () {
});
const __destroy = component.__destroy;
- component.__destroy = parent => {
+ component.__destroy = (parent) => {
model.off("update", component);
__destroy.call(component, parent);
};
diff --git a/addons/web/static/src/js/views/action_model.js b/addons/web/static/src/js/views/action_model.js
new file mode 100644
index 00000000000..382aa30531b
--- /dev/null
+++ b/addons/web/static/src/js/views/action_model.js
@@ -0,0 +1,190 @@
+odoo.define("web/static/src/js/views/action_model.js", function (require) {
+ "use strict";
+
+ const Domain = require("web.Domain");
+ const { FACET_ICONS } = require("web.searchUtils");
+ const { Model } = require("web/static/src/js/model.js");
+ const { parseArch } = require("web.viewUtils");
+ const pyUtils = require("web.py_utils");
+ const Registry = require("web.Registry");
+
+ const isNotNull = (value) => value !== null && value !== undefined;
+ const isObject = (obj) => typeof obj === "object" && obj !== null;
+
+ /**
+ * @extends Model.Extension
+ */
+ class ActionModelExtension extends Model.Extension {
+
+ //---------------------------------------------------------------------
+ // Static
+ //---------------------------------------------------------------------
+
+ /**
+ * @abstract
+ * @param {Object} archs
+ * @param {string | null} [viewType=null]
+ * @returns {null}
+ */
+ static extractArchInfo() {
+ return null;
+ }
+ }
+
+ /**
+ * @extends Model
+ */
+ class ActionModel extends Model {
+
+ //---------------------------------------------------------------------
+ // Public
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ get(property) {
+ switch (property) {
+ case "query": return this.config.searchQuery || this._getQuery();
+ case "facets": return this._getFacets();
+ }
+ return super.get(...arguments);
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ __get(excluded, property) {
+ const results = super.__get(...arguments);
+ switch (property) {
+ case "domain": return [this.config.domain, ...results];
+ case "context": return [this.config.context, ...results];
+ }
+ return results;
+ }
+
+ /**
+ * Validates and formats all facets given by the extensions. This is
+ * done here rather than in the search bar because the searchMenuTypes
+ * are available only to the model.
+ * @private
+ * @returns {Object[]}
+ */
+ _getFacets() {
+ const types = this.config.searchMenuTypes || [];
+ const isValidType = (type) => (
+ !['groupBy', 'comparison'].includes(type) || types.includes(type)
+ );
+ const facets = [];
+ for (const extension of this.extensions.flat()) {
+ for (const facet of extension.get("facets") || []) {
+ if (!isValidType(facet.type)) {
+ continue;
+ }
+ facet.separator = facet.type === 'groupBy' ? ">" : this.env._t("or");
+ if (facet.type in FACET_ICONS) {
+ facet.icon = FACET_ICONS[facet.type];
+ }
+ facets.push(facet);
+ }
+ }
+ return facets;
+ }
+
+ /**
+ * @typedef TimeRanges
+ * @property {string} fieldName
+ * @property {string} comparisonRangeId
+ * @property {Array[]} range
+ * @property {string} rangeDescription
+ * @property {Array[]} comparisonRange
+ * @property {string} comparisonRangeDescription
+ */
+ /**
+ * @typedef Query
+ * @property {Object} context
+ * @property {Array[]} domain
+ * @property {string[]} groupBy
+ * @property {string[]} orderedBy
+ * @property {TimeRanges?} timeRanges
+ */
+ /**
+ * @private
+ * @returns {Query}
+ */
+ _getQuery() {
+ const evalContext = this.env.session.user_context;
+ const contexts = this.__get(null, "context");
+ const domains = this.__get(null, "domain");
+ const query = {
+ context: pyUtils.eval("contexts", contexts, evalContext),
+ domain: Domain.prototype.normalizeArray(
+ pyUtils.eval("domains", domains, evalContext)
+ ),
+ orderedBy: this.get("orderedBy") || [],
+ };
+ const searchMenuTypes = this.config.searchMenuTypes || [];
+ if (searchMenuTypes.includes("groupBy")) {
+ query.groupBy = this.get("groupBy") || [];
+ } else {
+ query.groupBy = [];
+ }
+ if (searchMenuTypes.includes("comparison")) {
+ query.timeRanges = this.get("timeRanges") || {};
+ }
+ return query;
+ }
+
+ /**
+ * @override
+ */
+ _notifyComponents() {
+ this.trigger("search", this.get("query"));
+ super._notifyComponents(...arguments);
+ }
+
+ //---------------------------------------------------------------------
+ // Static
+ //---------------------------------------------------------------------
+
+ /**
+ * @param {Object} archs
+ * @param {string | null} [viewType=null]
+ * @returns {Object}
+ */
+ static extractArchInfo(archs, viewType = null) {
+ const parsedArchs = {};
+ if (!archs.search) {
+ archs.search = "";
+ }
+ for (const key in archs) {
+ const { attrs, children } = parseArch(archs[key]);
+ const objectChildren = children.filter(isObject);
+ parsedArchs[key] = {
+ attrs,
+ children: objectChildren,
+ };
+ }
+ const archInfo = {};
+ for (const key of this.registry.keys()) {
+ const extension = this.registry.get(key);
+ const result = extension.extractArchInfo(parsedArchs, viewType);
+ if (isNotNull(result)) {
+ archInfo[key] = result;
+ }
+ }
+ return archInfo;
+ }
+ }
+
+ ActionModel.Extension = ActionModelExtension;
+ ActionModel.registry = new Registry(null,
+ (value) => value.prototype instanceof ActionModel.Extension
+ );
+
+ return ActionModel;
+});
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index c0dc0097a38..e048691b37b 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -323,7 +323,7 @@
-
+
@@ -334,6 +334,7 @@
+