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 @@ +