From d6ffbfd479fcd235e6e8fdad2cbd84286ca359b6 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Mon, 13 Jul 2020 13:14:32 +0000 Subject: [IMP] web: Model: extensions registry Refactoring of the Model class of `model.js` to support multiple extensions. The purpose is to build a model having the appropriate modules for a given situation. For instance, we don't need the control panel model if we don't have a control panel to start with. Some logic of the control panel model (which was until now the "main" search model) has been given to another model extension named "Search Model Extension" which will synchronize the informations of all models providing search data (domain, context, etc.). The model instantiated in the actions/views is now an instance of the "Action Model" (which directly extends the Model) and is meant to replace the view model in the long term. It might however need some improvements to hold such a role (partial interface rendering, etc.). Part of task 2228968 Co-authored-by: Julien Mougenot Co-authored-by: Mathieu Duckerts-Antoine --- .../src/js/control_panel/control_panel_model.js | 1712 -------------------- .../control_panel/control_panel_model_extension.js | 1637 +++++++++++++++++++ addons/web/static/src/js/core/utils.js | 25 + addons/web/static/src/js/model.js | 453 +++++- addons/web/static/src/js/views/action_model.js | 190 +++ 5 files changed, 2249 insertions(+), 1768 deletions(-) delete mode 100644 addons/web/static/src/js/control_panel/control_panel_model.js create mode 100644 addons/web/static/src/js/control_panel/control_panel_model_extension.js create mode 100644 addons/web/static/src/js/views/action_model.js (limited to 'addons/web/static/src') 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.js deleted file mode 100644 index d438e5175cb..00000000000 --- a/addons/web/static/src/js/control_panel/control_panel_model.js +++ /dev/null @@ -1,1712 +0,0 @@ -odoo.define('web.ControlPanelModel', function (require) { - "use strict"; - - 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, - getComparisonOptions, getIntervalOptions, getPeriodOptions, - constructDateDomain, rankInterval, yearSelected } = require('web.searchUtils'); - - const FAVORITE_PRIVATE_GROUP = 1; - const FAVORITE_SHARED_GROUP = 2; - - let filterId = 1; - let groupId = 1; - let groupNumber = 0; - - /** - * Control panel model - * - * The control panel model state is an object structured in the following way: - * - * { - * filters: Object{}, - * query: Object[], - * } - * - *------------------------------------------------------------------------- - * Filters - *------------------------------------------------------------------------- - * - * The keys are stringified numbers called 'filter ids'. - * The values are objects called 'filters'. - * - * Each filter has the following properties: - * @prop {number} id unique identifier, also the filter's corresponding key - * @prop {number} groupId the id of some group, actually the group itself, - * the (active) 'groups' are reconstructed in _getGroups. - * @prop {string} description the description of the filter - * @prop {string} type 'filter'|'groupBy'|'comparison'|'field'|'favorite' - * - * Other properties can be present according to the corresponding filter type: - * - * • type 'comparison': - * @prop {string} comparisonOptionId option identifier (@see COMPARISON_OPTIONS). - * @prop {string} dateFilterId the id of a date filter (filter of type 'filter' - * with isDateFilter=true) - * - * • type 'filter': - * @prop {number} groupNumber used to separate items in the 'Filters' menu - * @prop {string} [context] context - * @prop {boolean} [invisible] determine if the filter is accessible in the interface - * @prop {boolean} [isDefault] - * @if isDefault = true: - * > @prop {number} [defaultRank=-5] used to determine the order of - * > activation of default filters - * @prop {boolean} [isDateFilter] true if the filter comes from an arch node - * with a valid 'date' attribute. - * @if isDateFilter = true - * > @prop {boolean} [hasOptions=true] - * > @prop {string} defaultOptionId option identifier determined by - * > default_period attribute (@see PERIOD_OPTIONS). - * > Default set to DEFAULT_PERIOD. - * > @prop {string} fieldName determined by the value of 'date' attribute - * > @prop {string} fieldType 'date' or 'datetime', type of the corresponding field - * @else - * > @prop {string} domain - * - * • type 'groupBy': - * @prop {string} fieldName - * @prop {string} fieldType - * @prop {number} groupNumber used to separate items in the 'Group by' menu - * @prop {boolean} [isDefault] - * @if isDefault = true: - * > @prop {number} defaultRank used to determine the order of activation - * > of default filters - * @prop {boolean} [invisible] determine if the filter is accessible in the interface - * @prop {boolean} [hasOptions] true if field type is 'date' or 'datetime' - * @if hasOptions=true - * > @prop {string} defaultOptionId option identifier (see INTERVAL_OPTIONS) - * default set to DEFAULT_INTERVAL. - * - * • type 'field': - * @prop {string} fieldName - * @prop {string} fieldType - * @prop {string} [context] - * @prop {string} [domain] - * @prop {string} [filterDomain] - * @prop {boolean} [invisible] determine if the filter is accessible in the interface - * @prop {boolean} [isDefault] - * @prop {string} [operator] - * @if isDefault = true: - * > @prop {number} [defaultRank=-10] used to determine the order of - * > activation of filters - * > @prop {Object} defaultAutocompleteValue of the form { value, label, operator } - * - * • type: 'favorite': - * @prop {Object} [comparison] of the form {comparisonId, fieldName, fieldDescription, - * range, rangeDescription, comparisonRange, comparisonRangeDescription, } - * @prop {Object} context - * @prop {string} domain - * @prop {string[]} groupBys - * @prop {number} groupNumber 1 | 2, 2 if the favorite is shared - * @prop {string[]} orderedBy - * @prop {boolean} [removable=true] indicates that the favorite can be deleted - * @prop {number} serverSideId - * @prop {number} userId - * @prop {boolean} [isDefault] - * - *------------------------------------------------------------------------- - * Query - *------------------------------------------------------------------------- - * - * The query elements are objects called 'query elements'. - * - * Each query element has the following properties: - * @prop {number} filterId the id of some filter - * @prop {number} groupId the id of some group (actually the group itself) - * - * Other properties must be defined according to the corresponding filter type. - * - * • type 'comparison': - * @prop {string} dateFilterId the id of a date filter (filter of type 'filter' - * with hasOptions=true) - * @prop {string} type 'comparison', help when searching if a comparison is active - * - * • type 'filter' with hasOptions=true: - * @prop {string} optionId option identifier (@see PERIOD_OPTIONS) - * - * • type 'groupBy' with hasOptions=true: - * @prop {string} optionId option identifier (@see INTERVAL_OPTIONS) - * - * • type 'field': - * @prop {string} label description put in the facet (can be temporarilly missing) - * @prop {(string|number)} value used as the value of the generated domain - * @prop {string} operator used as the operator of the generated domain - * - * 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 - */ - class ControlPanelModel extends Model { - /** - * @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.dynamicFilters=[]] - * @param {string[]} [config.searchMenuTypes=[]] - * @param {Object} [config.viewInfo={}] - * @param {boolean} [config.withSearchBar=true] - * - */ - constructor(config) { - super(); - - this._setProperties(config); - - if (this.withSearchBar) { - if (config.importedState) { - this.importState(config.importedState); - } else { - this._prepareInitialState(); - } - } - - this.isReady = Promise.all(this.labelPromises); - } - - //--------------------------------------------------------------------- - // Getters - //--------------------------------------------------------------------- - - /** - * @returns {(Object | undefined)} - */ - get activeComparison() { - return this.state.query.find(queryElem => queryElem.type === 'comparison'); - } - - //--------------------------------------------------------------------- - // Public - //--------------------------------------------------------------------- - - /** - * Activate a filter of type 'field' with given filterId with - * 'autocompleteValues' value, label, and operator. - * @param {Object} - */ - addAutoCompletionValues({ filterId, label, value, operator }) { - const queryElem = this.state.query.find(queryElem => - queryElem.filterId === filterId && - queryElem.value === value && - queryElem.operator === operator - ); - if (!queryElem) { - const { groupId } = this.state.filters[filterId]; - this.state.query.push({ filterId, groupId, label, value, operator }); - } else { - queryElem.label = label; - } - } - - /** - * Remove all the query elements from query. - */ - clearQuery() { - this.state.query = []; - } - - /** - * Create a new filter of type 'favorite' and activate it. - * A new group containing only that filter is created. - * The query is emptied before activating the new favorite. - * @param {Object} preFilter - * @returns {Promise} - */ - async createNewFavorite(preFilter) { - const preFavorite = await this._saveQuery(preFilter); - this.clearQuery(); - const filter = Object.assign(preFavorite, { - groupId, - id: filterId, - }); - this.state.filters[filterId] = filter; - this.state.query.push({ groupId, filterId }); - groupId++; - filterId++; - } - - /** - * Create new filters of type 'filter' and activate them. - * A new group containing only those filters is created. - * @param {Object[]} filters - * @returns {number[]} - */ - createNewFilters(prefilters) { - if (!prefilters.length) { - return []; - } - const newFilterIdS = []; - prefilters.forEach(preFilter => { - const filter = Object.assign(preFilter, { - groupId, - groupNumber, - id: filterId, - type: 'filter', - }); - this.state.filters[filterId] = filter; - this.state.query.push({ groupId, filterId }); - newFilterIdS.push(filterId); - filterId++; - }); - groupId++; - groupNumber++; - return newFilterIdS; - } - - /** - * Create a new filter of type 'groupBy' and activate it. - * It is added to the unique group of groupbys. - * @param {Object} field - */ - createNewGroupBy(field) { - const groupBy = Object.values(this.state.filters).find(f => f.type === 'groupBy'); - const filter = { - description: field.string || field.name, - fieldName: field.name, - fieldType: field.type, - groupId: groupBy ? groupBy.groupId : groupId++, - groupNumber, - id: filterId, - type: 'groupBy', - }; - this.state.filters[filterId] = filter; - if (['date', 'datetime'].includes(field.type)) { - filter.hasOptions = true; - filter.defaultOptionId = DEFAULT_INTERVAL; - this.toggleFilterWithOptions(filterId); - } else { - this.toggleFilter(filterId); - } - groupNumber++; - filterId++; - } - - /** - * Deactivate a group with provided groupId, i.e. delete the query elements - * with given groupId. - * @param {number} groupId - */ - deactivateGroup(groupId) { - this.state.query = this.state.query.filter( - queryElem => queryElem.groupId !== groupId - ); - this._checkComparisonStatus(); - } - - /** - * Delete a filter of type 'favorite' with given filterId server side and - * in control panel model. Of course the filter is also removed - * from the search query. - * @param {number} filterId - */ - async deleteFavorite(filterId) { - const { serverSideId } = this.state.filters[filterId]; - await this.env.dataManager.delete_filter(serverSideId); - const index = this.state.query.findIndex( - queryElem => queryElem.filterId === filterId - ); - delete this.state.filters[filterId]; - if (index >= 0) { - this.state.query.splice(index, 1); - } - } - - /** - * 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; - }; - - 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 an array containing enriched copies of the filters of the provided type. - * @param {string} type - * @returns {Object[]} - */ - 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); - } - 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. - */ - getQuery() { - 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; - } - - /** - * Allow to reuse the state of a previous control panel model with same modelName. - * This is mainly used when switching views. - * @param {Object} state - */ - importState(state) { - Object.assign(this.state, state); - } - - /** - * 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. - * @param {string} filterId - */ - toggleComparison(filterId) { - const { groupId, dateFilterId } = this.state.filters[filterId]; - const queryElem = this.state.query.find(queryElem => - queryElem.type === 'comparison' && - queryElem.filterId === filterId - ); - // make sure only one comparison can be active - this.state.query = this.state.query.filter(queryElem => queryElem.type !== 'comparison'); - if (!queryElem) { - this.state.query.push({ groupId, filterId, dateFilterId, type: 'comparison', }); - } - } - - /** - * Activate or deactivate the simple filter with given filterId, i.e. - * add or remove a corresponding query element. - * @param {string} filterId - */ - toggleFilter(filterId) { - const index = this.state.query.findIndex( - queryElem => queryElem.filterId === filterId - ); - if (index >= 0) { - this.state.query.splice(index, 1); - } else { - const { groupId, type } = this.state.filters[filterId]; - if (type === 'favorite') { - this.state.query = []; - } - this.state.query.push({ groupId, filterId }); - } - } - - /** - * Used to toggle a query element { filterId, optionId, (groupId) }. - * This can impact the query in various form, e.g. add/remove other query elements - * in case the filter is of type 'filter'. - * @param {string} filterId - * @param {string} [optionId] - */ - toggleFilterWithOptions(filterId, optionId) { - const filter = this.state.filters[filterId]; - optionId = optionId || filter.defaultOptionId; - const option = this.optionGenerators.find(o => o.id === optionId); - - const index = this.state.query.findIndex( - queryElem => queryElem.filterId === filterId && queryElem.optionId === optionId - ); - - if (index >= 0) { - this.state.query.splice(index, 1); - if (filter.type === 'filter' && !yearSelected(this._getSelectedOptionIds(filterId))) { - // This is the case where optionId was the last option - // of type 'year' to be there before being removed above. - // Since other options of type 'month' or 'quarter' do - // not make sense without a year we deactivate all options. - this.state.query = this.state.query.filter( - queryElem => queryElem.filterId !== filterId - ); - } - } else { - this.state.query.push({ groupId: filter.groupId, filterId, optionId }); - if (filter.type === 'filter' && !yearSelected(this._getSelectedOptionIds(filterId))) { - // Here we add 'this_year' as options if no option of type - // year is already selected. - this.state.query.push({ - groupId: filter.groupId, - filterId, - optionId: option.defaultYearId, - }); - } - } - if (filter.type === 'filter') { - this._checkComparisonStatus(); - } - } - - /** - * 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 - //--------------------------------------------------------------------- - - /** - * Activate the default favorite (if any) or all default filters. - * @private - */ - _activateDefaultFilters() { - if (this.defaultFavoriteId) { - // Activate default favorite - this.toggleFilter(this.defaultFavoriteId); - } else { - // Activate default filters - Object.values(this.state.filters) - .filter((f) => f.isDefault && f.type !== 'favorite') - .sort((f1, f2) => (f1.defaultRank || 100) - (f2.defaultRank || 100)) - .forEach(f => { - if (f.hasOptions) { - this.toggleFilterWithOptions(f.id); - } else if (f.type === 'field') { - let { operator, label, value } = f.defaultAutocompleteValue; - this.addAutoCompletionValues({ - filterId: f.id, - value, - operator, - label, - }); - } else { - this.toggleFilter(f.id); - } - }); - } - } - - /** - * This function populates the 'filters' object at initialization. - * The filters come from: - * - config.viewInfo.arch (types 'comparison', 'filter', 'groupBy', 'field'), - * - config.dynamicFilters (type 'filter'), - * - config.viewInfo.favoriteFilters (type 'favorite'), - * @private - */ - _addFilters() { - this._createGroupOfFavorites(); - this._createGroupOfFiltersFromArch(); - this._createGroupOfDynamicFilters(); - } - - /** - * If a comparison is active, check if it should become inactive. - * The comparison should become inactive if the corresponding date filter has become - * inactive. - * @private - */ - _checkComparisonStatus() { - const activeComparison = this.activeComparison; - if (!activeComparison) { - return; - } - const { dateFilterId } = activeComparison; - const dateFilterIsActive = this.state.query.some( - queryElem => queryElem.filterId === dateFilterId - ); - if (!dateFilterIsActive) { - this.state.query = this.state.query.filter( - queryElem => queryElem.type !== 'comparison' - ); - } - } - - /** - * Returns the active comparison timeRanges object. - * @private - * @param {Object} comparisonFilter - * @returns {Object | undefined} - */ - _computeTimeRanges(comparisonFilter) { - const { filterId } = this.activeComparison; - if (filterId !== comparisonFilter.id) { - return; - } - const { dateFilterId, comparisonOptionId } = comparisonFilter; - const { - fieldName, - fieldType, - description: dateFilterDescription, - } = this.state.filters[dateFilterId]; - - const selectedOptionIds = this._getSelectedOptionIds(dateFilterId); - - // compute range and range description - const { domain: range, description: rangeDescription } = constructDateDomain( - this.referenceMoment, fieldName, fieldType, selectedOptionIds, - ); - - // compute comparisonRange and comparisonRange description - const { - domain: comparisonRange, - description: comparisonRangeDescription, - } = constructDateDomain( - this.referenceMoment, fieldName, fieldType, selectedOptionIds, comparisonOptionId - ); - - return { - comparisonId: comparisonOptionId, - fieldName, - fieldDescription: dateFilterDescription, - range, - rangeDescription, - comparisonRange, - comparisonRangeDescription, - }; - } - - /** - * Starting from the array of date filters, create the filters of type - * 'comparison'. - * @private - * @param {Object[]} dateFilters - */ - _createGroupOfComparisons(dateFilters) { - const preFilters = []; - for (const dateFilter of dateFilters) { - for (const comparisonOption of this.comparisonOptions) { - const { id: dateFilterId, description } = dateFilter; - const preFilter = { - type: 'comparison', - comparisonOptionId: comparisonOption.id, - description: `${description}: ${comparisonOption.description}`, - dateFilterId, - }; - preFilters.push(preFilter); - } - } - this._createGroupOfFilters(preFilters); - } - - /** - * Add filters of type 'filter' determined by the key array this.dynamicFilters. - * @private - */ - _createGroupOfDynamicFilters() { - const pregroup = this.dynamicFilters.map(filter => { - return { - description: filter.description, - domain: JSON.stringify(filter.domain), - isDefault: true, - type: 'filter', - }; - }); - this._createGroupOfFilters(pregroup); - } - - /** - * Add filters of type 'favorite' determined by array this.favoriteFilters. - * @private - */ - _createGroupOfFavorites() { - this.favoriteFilters.forEach(irFilter => { - const favorite = this._irFilterToFavorite(irFilter); - this._createGroupOfFilters([favorite]); - if (this.activateDefaultFavorite && favorite.isDefault) { - this.defaultFavoriteId = favorite.id; - } - }); - } - - /** - * Using a list (a 'pregroup') of 'prefilters', create new filters in `state.filters` - * for each prefilter. The new filters belong to a same new group. - * @private - * @param {Object[]} pregroup, list of 'prefilters' - * @param {string} type - */ - _createGroupOfFilters(pregroup) { - pregroup.forEach(preFilter => { - const filter = Object.assign(preFilter, { groupId, id: filterId }); - this.state.filters[filterId] = filter; - if (!this.defaultFavoriteId && filter.isDefault && filter.type === 'field') { - this._prepareDefaultLabel(filter); - } - filterId++; - }); - groupId++; - } - - /** - * Parse the arch of a 'search' view and create corresponding filters and groups. - * - * A searchview arch may contain a 'searchpanel' node, but this isn't - * the concern of the ControlPanel (the SearchPanel will handle it). - * Ideally, this code should whitelist the tags to take into account - * instead of blacklisting the others, but with the current (messy) - * structure of a searchview arch, it's way simpler to do it that way. - * @private - */ - _createGroupOfFiltersFromArch() { - const children = this.parsedArch.children.filter( - child => child instanceof Object && child.tag !== 'searchpanel' - ); - const preFilters = children.reduce( - (preFilters, child) => { - if (child.tag === 'group') { - return [...preFilters, ...child.children.map(c => this._evalArchChild(c))]; - } else { - return [...preFilters, this._evalArchChild(child)]; - } - }, - [] - ); - preFilters.push({ tag: 'separator' }); - - // create groups and filters - let currentTag; - let currentGroup = []; - let pregroupOfGroupBys = []; - - preFilters.forEach(preFilter => { - if ( - preFilter.tag !== currentTag || - ['separator', 'field'].includes(preFilter.tag) - ) { - if (currentGroup.length) { - if (currentTag === 'groupBy') { - pregroupOfGroupBys = [...pregroupOfGroupBys, ...currentGroup]; - } else { - this._createGroupOfFilters(currentGroup); - } - } - currentTag = preFilter.tag; - currentGroup = []; - groupNumber++; - } - if (preFilter.tag !== 'separator') { - const filter = { - type: preFilter.tag, - // we need to codify here what we want to keep from attrs - // and how, for now I put everything. - // In some sence, some filter are active (totally determined, given) - // 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; - } - this._extractAttributes(filter, preFilter.attrs); - currentGroup.push(filter); - } - }); - - if (pregroupOfGroupBys.length) { - this._createGroupOfFilters(pregroupOfGroupBys); - } - const dateFilters = Object.values(this.state.filters).filter( - (filter) => filter.isDateFilter - ); - if (dateFilters.length) { - this._createGroupOfComparisons(dateFilters); - } - } - - /** - * 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 - * 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 - * for some reason. - * @private - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @returns {Object|undefined} - */ - _enrichFilterCopy(filter, filterQueryElements) { - const isActive = Boolean(filterQueryElements.length); - const f = Object.assign({ isActive }, filter); - - function _enrichOptions(options) { - return options.map(o => { - const { description, id, groupNumber } = o; - const isActive = filterQueryElements.some(a => a.optionId === id); - return { description, id, groupNumber, isActive }; - }); - } - - switch (f.type) { - case 'comparison': { - const { dateFilterId } = filter; - const dateFilterIsActive = this.state.query.some( - queryElem => queryElem.filterId === dateFilterId - ); - if (!dateFilterIsActive) { - return; - } - break; - } - case 'filter': - if (f.hasOptions) { - f.options = _enrichOptions(this.optionGenerators); - } - break; - case 'groupBy': - if (f.hasOptions) { - f.options = _enrichOptions(this.intervalOptions); - } - break; - case 'field': - f.autoCompleteValues = filterQueryElements.map( - ({ label, value, operator }) => ({ label, value, operator }) - ); - break; - } - return f; - } - - /** - * Process a given arch node and enrich it. - * @private - * @param {Object} child - * @returns {Object} - */ - _evalArchChild(child) { - if (child.attrs.context) { - try { - const context = pyUtils.eval('context', child.attrs.context); - child.attrs.context = context; - if (context.group_by) { - // let us extract basic data since we just evaluated context - // and use a correct tag! - child.attrs.fieldName = context.group_by.split(':')[0]; - child.attrs.defaultInterval = context.group_by.split(':')[1]; - child.tag = 'groupBy'; - } - } catch (e) { } - } - if (this.searchDefaults.hasOwnProperty(child.attrs.name)) { - child.attrs.isDefault = true; - let value = this.searchDefaults[child.attrs.name]; - if (child.tag === 'field') { - if (value instanceof Array) { - value = value[0]; - } - child.attrs.defaultAutocompleteValue = { value, operator: '=' }; - } else if (child.tag === 'groupBy') { - child.attrs.defaultRank = typeof value === 'number' ? value : 100; - } - } - return child; - } - - /** - * Process the attributes set on an arch node and adds various keys to - * the given filter. - * @private - * @param {Object} filter - * @param {Object} attrs - */ - _extractAttributes(filter, attrs) { - if (attrs.isDefault) { - filter.isDefault = attrs.isDefault; - } - filter.description = attrs.string || attrs.help || attrs.name || attrs.domain || 'Ω'; - switch (filter.type) { - case 'filter': - if (attrs.context) { - filter.context = attrs.context; - } - if (attrs.date) { - filter.isDateFilter = true; - filter.hasOptions = true; - filter.fieldName = attrs.date; - filter.fieldType = this.fields[attrs.date].type; - filter.defaultOptionId = attrs.default_period || DEFAULT_PERIOD; - } else { - filter.domain = attrs.domain || '[]'; - } - if (filter.isDefault) { - filter.defaultRank = -5; - } - break; - case 'groupBy': - filter.fieldName = attrs.fieldName; - filter.fieldType = this.fields[attrs.fieldName].type; - if (['date', 'datetime'].includes(filter.fieldType)) { - filter.hasOptions = true; - filter.defaultOptionId = attrs.defaultInterval || DEFAULT_INTERVAL; - } - if (filter.isDefault) { - filter.defaultRank = attrs.defaultRank; - } - break; - case 'field': { - const field = this.fields[attrs.name]; - filter.fieldName = attrs.name; - filter.fieldType = field.type; - if (attrs.domain) { - filter.domain = attrs.domain; - } - if (attrs.filter_domain) { - filter.filterDomain = attrs.filter_domain; - } else if (attrs.operator) { - filter.operator = attrs.operator; - } - if (attrs.context) { - filter.context = attrs.context; - } - if (filter.isDefault) { - filter.defaultRank = -10; - filter.defaultAutocompleteValue = attrs.defaultAutocompleteValue; - } - if (attrs.widget) { // FIXME: drop support in master - filter.widget = attrs.widget; - } - break; - } - } - if (filter.fieldName && !attrs.string) { - const { string } = this.fields[filter.fieldName]; - filter.description = string; - } - } - - /** - * Returns an object irFilter serving to create an ir_filte in db - * starting from a filter of type 'favorite'. - * @private - * @param {Object} favorite - * @returns {Object} - */ - _favoriteToIrFilter(favorite) { - const irFilter = { - action_id: this.actionId, - model_id: this.modelName, - }; - - // ir.filter fields - if ('description' in favorite) { - irFilter.name = favorite.description; - } - if ('domain' in favorite) { - irFilter.domain = favorite.domain; - } - if ('isDefault' in favorite) { - irFilter.is_default = favorite.isDefault; - } - if ('orderedBy' in favorite) { - const sort = favorite.orderedBy.map( - ob => ob.name + (ob.asc === false ? " desc" : "") - ); - irFilter.sort = JSON.stringify(sort); - } - if ('serverSideId' in favorite) { - irFilter.id = favorite.serverSideId; - } - if ('userId' in favorite) { - irFilter.user_id = favorite.userId; - } - - // Context - const context = Object.assign({}, favorite.context); - if ('groupBys' in favorite) { - context.group_by = favorite.groupBys; - } - if ('comparison' in favorite) { - context.comparison = favorite.comparison; - } - if (Object.keys(context).length) { - irFilter.context = context; - } - - return irFilter; - } - - /** - * Return the domain resulting from the combination of the auto-completion - * values of a filter of type 'field'. - * @private - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @returns {string} - */ - _getAutoCompletionFilterDomain(filter, filterQueryElements) { - const domains = filterQueryElements.map(({ label, value, operator }) => { - let domain; - if (filter.filterDomain) { - domain = Domain.prototype.stringToArray( - filter.filterDomain, - { - self: label, - raw_value: value, - } - ); - } else { - // Create new domain - domain = [[filter.fieldName, operator, value]]; - } - return Domain.prototype.arrayToString(domain); - }); - return pyUtils.assembleDomains(domains, 'OR'); - } - - /** - * Construct a single context from the contexts of - * filters of type 'filter', 'favorite', and 'field'. - * @private - * @returns {Object} - */ - _getContext(groups, withActionContext = true) { - const types = ['filter', 'favorite', 'field']; - const contexts = groups.reduce( - (contexts, group) => { - if (types.includes(group.type)) { - contexts.push(...this._getGroupContexts(group)); - } - return contexts; - }, - [] - ); - if (withActionContext) { - contexts.unshift(this.actionContext); - } - const evaluationContext = this.env.session.user_context; - try { - return pyUtils.eval('contexts', contexts, evaluationContext); - } catch (err) { - throw new Error( - this.env._t("Failed to evaluate search context") + ":\n" + - JSON.stringify(err) - ); - } - } - - /** - * Compute the string representation or the description of the current domain associated - * with a date filter starting from its corresponding query elements. - * @private - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @param {'domain'|'description'} [key='domain'] - * @returns {string} - */ - _getDateFilterDomain(filter, filterQueryElements, key = 'domain') { - const { fieldName, fieldType } = filter; - const selectedOptionIds = filterQueryElements.map(queryElem => queryElem.optionId); - const dateFilterRange = constructDateDomain( - this.referenceMoment, fieldName, fieldType, selectedOptionIds, - ); - return dateFilterRange[key]; - } - - /** - * Return the string or array representation of a domain created by combining - * appropriately (with an 'AND') the domains coming from the active groups - * of type 'filter', 'favorite', and 'field'. - * @private - * @param {Object[]} groups - * @param {boolean} [evaluation=true] - * @returns {string} - */ - _getDomain(groups, evaluation = true) { - 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'); - - 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) - ); - } - } else { - return filterDomain; - } - } - - /** - * Return the context of the provided (active) filter. - * @private - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @returns {Object} - */ - _getFilterContext(filter, filterQueryElements) { - let context = filter.context || {}; - // for nodes, a dynamic context (like context="{'field1': self}") - // should set {'field1': [value1, value2]} in the context - if (filter.type === 'field' && filter.context) { - context = pyUtils.eval('context', - filter.context, - { self: filterQueryElements.map(({ value }) => value) }, - ); - } - // the following code aims to remodel this: - // https://github.com/odoo/odoo/blob/12.0/addons/web/static/src/js/views/search/search_inputs.js#L498 - // this is required for the helpdesk tour to pass - // this seems weird to only do that for m2o fields, but a test fails if - // we do it for other fields (my guess being that the test should simply - // be adapted) - if (filter.type === 'field' && filter.isDefault && filter.fieldType === 'many2one') { - context[`default_${filter.fieldName}`] = filter.defaultAutocompleteValue.value; - } - return context; - } - - /** - * Return the domain of the provided filter. - * @private - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @returns {string} domain, string representation of a domain - */ - _getFilterDomain(filter, filterQueryElements) { - if (filter.type === 'filter' && filter.hasOptions) { - const { dateFilterId } = this.activeComparison || {}; - if (this.searchMenuTypes.includes('comparison') && dateFilterId === filter.id) { - return "[]"; - } - return this._getDateFilterDomain(filter, filterQueryElements); - } else if (filter.type === 'field') { - return this._getAutoCompletionFilterDomain(filter, filterQueryElements); - } - return filter.domain; - } - - /** - * Return the groupBys of the provided filter. - * @private - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @returns {string[]} groupBys - */ - _getFilterGroupBys(filter, filterQueryElements) { - if (filter.type === 'groupBy') { - const fieldName = filter.fieldName; - if (filter.hasOptions) { - return filterQueryElements.map( - ({ optionId }) => `${fieldName}:${optionId}` - ); - } else { - return [fieldName]; - } - } else { - return filter.groupBys; - } - } - - /** - * Return the concatenation of groupBys comming from the active filters of - * type 'favorite' and 'groupBy'. - * The result respects the appropriate logic: the groupBys - * coming from an active favorite (if any) come first, then come the - * groupBys comming from the active filters of type 'groupBy' in the order - * defined in this.state.query. If no groupBys are found, one tries to - * find some grouBys in the action context. - * @private - * @param {Object[]} groups - * @returns {string[]} - */ - _getGroupBy(groups) { - const groupBys = groups.reduce( - (groupBys, group) => { - if (['groupBy', 'favorite'].includes(group.type)) { - groupBys.push(...this._getGroupGroupBys(group)); - } - return groupBys; - }, - [] - ); - const groupBy = groupBys.length ? groupBys : (this.actionContext.group_by || []); - return typeof groupBy === 'string' ? [groupBy] : groupBy; - } - - /** - * Return the list of the contexts of the filters active in the given - * group. - * @private - * @param {Object} group - * @returns {Object[]} - */ - _getGroupContexts(group) { - const contexts = group.activities.reduce( - (ctx, qe) => [...ctx, this._getFilterContext(qe.filter, qe.filterQueryElements)], - [] - ); - return contexts; - } - - /** - * Return the string representation of a domain created by combining - * appropriately (with an 'OR') the domains coming from the filters - * active in the given group. - * @private - * @param {Object} group - * @returns {string} string representation of a domain - */ - _getGroupDomain(group) { - const domains = group.activities.map(({ filter, filterQueryElements }) => { - return this._getFilterDomain(filter, filterQueryElements); - }); - return pyUtils.assembleDomains(domains, 'OR'); - } - - /** - * Return the groupBys coming form the filters active in the given group. - * @private - * @param {Object} group - * @returns {string[]} - */ - _getGroupGroupBys(group) { - const groupBys = group.activities.reduce( - (gb, qe) => [...gb, ...this._getFilterGroupBys(qe.filter, qe.filterQueryElements)], - [] - ); - return groupBys; - } - - /** - * Reconstruct the (active) groups from the query elements. - * @private - * @returns {Object[]} - */ - _getGroups() { - const groups = this.state.query.reduce( - (groups, queryElem) => { - const { groupId, filterId } = queryElem; - let group = groups.find(group => group.id === groupId); - const filter = this.state.filters[filterId]; - if (!group) { - const { type } = filter; - group = { - id: groupId, - type, - activities: [] - }; - groups.push(group); - } - group.activities.push(queryElem); - return groups; - }, - [] - ); - groups.forEach(g => this._mergeActivities(g)); - return groups; - } - - /** - * Used to get the key orderedBy of the active favorite. - * @private - * @param {Object[]} groups - * @returns {string[]} orderedBy - */ - _getOrderedBy(groups) { - return groups.reduce( - (orderedBy, group) => { - if (group.type === 'favorite') { - const favoriteOrderedBy = group.activities[0].filter.orderedBy; - if (favoriteOrderedBy) { - // Group order is reversed but inner order is kept - orderedBy = [...favoriteOrderedBy, ...orderedBy]; - } - } - return orderedBy; - }, - [] - ); - } - - /** - * Starting from the id of a date filter, returns the array of option ids currently selected - * for the corresponding filter. - * @private - * @param {string} dateFilterId - * @returns {string[]} - */ - _getSelectedOptionIds(dateFilterId) { - const selectedOptionIds = []; - for (const queryElem of this.state.query) { - if (queryElem.filterId === dateFilterId) { - selectedOptionIds.push(queryElem.optionId); - } - } - return selectedOptionIds; - } - - /** - * Returns a filter of type 'favorite' starting from an ir_filter comming from db. - * @private - * @param {Object} irFilter - * @returns {Object} - */ - _irFilterToFavorite(irFilter) { - let userId = irFilter.user_id || false; - if (Array.isArray(userId)) { - userId = userId[0]; - } - const groupNumber = userId ? FAVORITE_PRIVATE_GROUP : FAVORITE_SHARED_GROUP; - const context = pyUtils.eval('context', irFilter.context, this.env.session.user_context); - let groupBys = []; - if (context.group_by) { - groupBys = context.group_by; - delete context.group_by; - } - let comparison; - if (context.comparison) { - comparison = context.comparison; - delete context.comparison; - } - let sort; - try { - sort = JSON.parse(irFilter.sort); - } catch (err) { - if (err instanceof SyntaxError) { - sort = []; - } else { - throw err; - } - } - const orderedBy = sort.map(order => { - let fieldName; - let asc; - const sqlNotation = order.split(' '); - if (sqlNotation.length > 1) { - // regex: \fieldName (asc|desc)?\ - fieldName = sqlNotation[0]; - asc = sqlNotation[1] === 'asc'; - } else { - // legacy notation -- regex: \-?fieldName\ - fieldName = order[0] === '-' ? order.slice(1) : order; - asc = order[0] === '-' ? false : true; - } - return { - asc: asc, - name: fieldName, - }; - }); - const favorite = { - context, - description: irFilter.name, - domain: irFilter.domain, - groupBys, - groupNumber, - orderedBy, - removable: true, - serverSideId: irFilter.id, - type: 'favorite', - userId, - }; - if (irFilter.is_default) { - favorite.isDefault = irFilter.is_default; - } - if (comparison) { - favorite.comparison = comparison; - } - return favorite; - } - - /** - * Group the query elements in group.activities by qe -> qe.filterId - * and changes the form of group.activities to make it more suitable for further - * computations. - * @private - * @param {Object} group - */ - _mergeActivities(group) { - const { activities, type } = group; - let res = []; - switch (type) { - case 'filter': - case 'groupBy': { - for (const activity of activities) { - const { filterId } = activity; - let a = res.find(({ filter }) => filter.id === filterId); - if (!a) { - a = { - filter: this.state.filters[filterId], - filterQueryElements: [] - }; - res.push(a); - } - a.filterQueryElements.push(activity); - } - break; - } - case 'favorite': - case 'field': - case 'comparison': { - // all activities in the group have same filterId - const { filterId } = group.activities[0]; - const filter = this.state.filters[filterId]; - res.push({ - filter, - filterQueryElements: group.activities - }); - break; - } - } - if (type === 'groupBy') { - res.forEach(activity => { - activity.filterQueryElements.sort( - (qe1, qe2) => rankInterval(qe1.optionId) - rankInterval(qe2.optionId) - ); - }); - } - group.activities = res; - } - - /** - * Set the key label in defaultAutocompleteValue used by default filters of - * type 'field'. - * @private - * @param {Object} filter - */ - _prepareDefaultLabel(filter) { - const { id, fieldType, fieldName, defaultAutocompleteValue } = filter; - const { selection, context, relation } = this.fields[fieldName]; - if (fieldType === 'selection') { - defaultAutocompleteValue.label = selection.find( - sel => sel[0] === defaultAutocompleteValue.value - )[1]; - } else if (fieldType === 'many2one') { - const updateLabel = label => { - const queryElem = this.state.query.find(({ filterId }) => filterId === id); - queryElem.label = label; - defaultAutocompleteValue.label = label; - }; - const promise = this.env.services.rpc({ - args: [defaultAutocompleteValue.value], - context: context, - method: 'name_get', - model: relation, - }) - .then(results => updateLabel(results[0][1])) - .guardedCatch(() => updateLabel(defaultAutocompleteValue.value)); - this.labelPromises.push(promise); - } else { - defaultAutocompleteValue.label = defaultAutocompleteValue.value; - } - } - - /** - * @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. - * If the operation is successful, a new filter of type 'favorite' is - * created and activated. - * @private - * @param {Object} preFilter - * @returns {Promise} - */ - async _saveQuery(preFilter) { - const groups = this._getGroups(); - - const userContext = this.env.session.user_context; - let controllerQueryParams; - this.trigger( - 'get-controller-query-params', - p => { - controllerQueryParams = p; - } - ); - controllerQueryParams = controllerQueryParams || {}; - controllerQueryParams.context = controllerQueryParams.context || {}; - - const withoutActiveContext = false; - const queryContext = this._getContext(groups, withoutActiveContext); - const context = pyUtils.eval( - 'contexts', - [userContext, controllerQueryParams.context, queryContext] - ); - for (const key in userContext) { - delete context[key]; - } - - const requireEvaluation = false; - const domain = this._getDomain(groups, requireEvaluation); - const groupBys = this._getGroupBy(groups); - const timeRanges = this._getTimeRanges(requireEvaluation); - const orderedBy = controllerQueryParams.orderedBy ? - controllerQueryParams.orderedBy : - (this._getOrderedBy(groups) || []); - - const userId = preFilter.isShared ? false : this.env.session.uid; - delete preFilter.isShared; - - Object.assign(preFilter, { - context, - domain, - groupBys, - groupNumber: userId ? FAVORITE_PRIVATE_GROUP : FAVORITE_SHARED_GROUP, - orderedBy, - removable: true, - userId, - }); - if (timeRanges) { - preFilter.comparison = timeRanges; - } - const irFilter = this._favoriteToIrFilter(preFilter); - const serverSideId = await this.env.dataManager.create_filter(irFilter); - - preFilter.serverSideId = serverSideId; - - 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; - } - - /** - * 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)} - */ - _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: [], - }; - 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]; - } - } - 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 ControlPanelModel; -}); diff --git a/addons/web/static/src/js/control_panel/control_panel_model_extension.js b/addons/web/static/src/js/control_panel/control_panel_model_extension.js new file mode 100644 index 00000000000..ef443fbb70a --- /dev/null +++ b/addons/web/static/src/js/control_panel/control_panel_model_extension.js @@ -0,0 +1,1637 @@ +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 pyUtils = require('web.py_utils'); + + 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; + let groupNumber = 0; + + /** + * Control panel model + * + * The control panel model state is an object structured in the following way: + * + * { + * filters: Object{}, + * query: Object[], + * } + * + *------------------------------------------------------------------------- + * Filters + *------------------------------------------------------------------------- + * + * The keys are stringified numbers called 'filter ids'. + * The values are objects called 'filters'. + * + * Each filter has the following properties: + * @prop {number} id unique identifier, also the filter's corresponding key + * @prop {number} groupId the id of some group, actually the group itself, + * the (active) 'groups' are reconstructed in _getGroups. + * @prop {string} description the description of the filter + * @prop {string} type 'filter'|'groupBy'|'comparison'|'field'|'favorite' + * + * Other properties can be present according to the corresponding filter type: + * + * • type 'comparison': + * @prop {string} comparisonOptionId option identifier (@see COMPARISON_OPTIONS). + * @prop {string} dateFilterId the id of a date filter (filter of type 'filter' + * with isDateFilter=true) + * + * • type 'filter': + * @prop {number} groupNumber used to separate items in the 'Filters' menu + * @prop {string} [context] context + * @prop {boolean} [invisible] determine if the filter is accessible in the interface + * @prop {boolean} [isDefault] + * @if isDefault = true: + * > @prop {number} [defaultRank=-5] used to determine the order of + * > activation of default filters + * @prop {boolean} [isDateFilter] true if the filter comes from an arch node + * with a valid 'date' attribute. + * @if isDateFilter = true + * > @prop {boolean} [hasOptions=true] + * > @prop {string} defaultOptionId option identifier determined by + * > default_period attribute (@see PERIOD_OPTIONS). + * > Default set to DEFAULT_PERIOD. + * > @prop {string} fieldName determined by the value of 'date' attribute + * > @prop {string} fieldType 'date' or 'datetime', type of the corresponding field + * @else + * > @prop {string} domain + * + * • type 'groupBy': + * @prop {string} fieldName + * @prop {string} fieldType + * @prop {number} groupNumber used to separate items in the 'Group by' menu + * @prop {boolean} [isDefault] + * @if isDefault = true: + * > @prop {number} defaultRank used to determine the order of activation + * > of default filters + * @prop {boolean} [invisible] determine if the filter is accessible in the interface + * @prop {boolean} [hasOptions] true if field type is 'date' or 'datetime' + * @if hasOptions=true + * > @prop {string} defaultOptionId option identifier (see INTERVAL_OPTIONS) + * default set to DEFAULT_INTERVAL. + * + * • type 'field': + * @prop {string} fieldName + * @prop {string} fieldType + * @prop {string} [context] + * @prop {string} [domain] + * @prop {string} [filterDomain] + * @prop {boolean} [invisible] determine if the filter is accessible in the interface + * @prop {boolean} [isDefault] + * @prop {string} [operator] + * @if isDefault = true: + * > @prop {number} [defaultRank=-10] used to determine the order of + * > activation of filters + * > @prop {Object} defaultAutocompleteValue of the form { value, label, operator } + * + * • type: 'favorite': + * @prop {Object} [comparison] of the form {comparisonId, fieldName, fieldDescription, + * range, rangeDescription, comparisonRange, comparisonRangeDescription, } + * @prop {Object} context + * @prop {string} domain + * @prop {string[]} groupBys + * @prop {number} groupNumber 1 | 2, 2 if the favorite is shared + * @prop {string[]} orderedBy + * @prop {boolean} [removable=true] indicates that the favorite can be deleted + * @prop {number} serverSideId + * @prop {number} userId + * @prop {boolean} [isDefault] + * + *------------------------------------------------------------------------- + * Query + *------------------------------------------------------------------------- + * + * The query elements are objects called 'query elements'. + * + * Each query element has the following properties: + * @prop {number} filterId the id of some filter + * @prop {number} groupId the id of some group (actually the group itself) + * + * Other properties must be defined according to the corresponding filter type. + * + * • type 'comparison': + * @prop {string} dateFilterId the id of a date filter (filter of type 'filter' + * with hasOptions=true) + * @prop {string} type 'comparison', help when searching if a comparison is active + * + * • type 'filter' with hasOptions=true: + * @prop {string} optionId option identifier (@see PERIOD_OPTIONS) + * + * • type 'groupBy' with hasOptions=true: + * @prop {string} optionId option identifier (@see INTERVAL_OPTIONS) + * + * • type 'field': + * @prop {string} label description put in the facet (can be temporarilly missing) + * @prop {(string|number)} value used as the value of the generated domain + * @prop {string} operator used as the operator of the generated domain + * + * 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 ActionModel.Extension + */ + class ControlPanelModelExtension extends ActionModel.Extension { + /** + * @param {Object} config + * @param {(string|number)} config.actionId + * @param {Object} config.env + * @param {string} config.modelName + * @param {Object} [config.context={}] + * @param {Object[]} [config.archNodes=[]] + * @param {Object[]} [config.dynamicFilters=[]] + * @param {string[]} [config.searchMenuTypes=[]] + * @param {Object} [config.favoriteFilters={}] + * @param {Object} [config.fields={}] + * @param {boolean} [config.withSearchBar=true] + */ + constructor() { + super(...arguments); + + 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.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. + * @param {Object} + */ + addAutoCompletionValues({ filterId, label, value, operator }) { + const queryElem = this.state.query.find(queryElem => + queryElem.filterId === filterId && + queryElem.value === value && + queryElem.operator === operator + ); + if (!queryElem) { + const { groupId } = this.state.filters[filterId]; + this.state.query.push({ filterId, groupId, label, value, operator }); + } else { + queryElem.label = label; + } + } + + /** + * Remove all the query elements from query. + */ + clearQuery() { + this.state.query = []; + } + + /** + * Create a new filter of type 'favorite' and activate it. + * A new group containing only that filter is created. + * The query is emptied before activating the new favorite. + * @param {Object} preFilter + * @returns {Promise} + */ + async createNewFavorite(preFilter) { + const preFavorite = await this._saveQuery(preFilter); + this.clearQuery(); + const filter = Object.assign(preFavorite, { + groupId, + id: filterId, + }); + this.state.filters[filterId] = filter; + this.state.query.push({ groupId, filterId }); + groupId++; + filterId++; + } + + /** + * Create new filters of type 'filter' and activate them. + * A new group containing only those filters is created. + * @param {Object[]} filters + * @returns {number[]} + */ + createNewFilters(prefilters) { + if (!prefilters.length) { + return []; + } + const newFilterIdS = []; + prefilters.forEach(preFilter => { + const filter = Object.assign(preFilter, { + groupId, + groupNumber, + id: filterId, + type: 'filter', + }); + this.state.filters[filterId] = filter; + this.state.query.push({ groupId, filterId }); + newFilterIdS.push(filterId); + filterId++; + }); + groupId++; + groupNumber++; + return newFilterIdS; + } + + /** + * Create a new filter of type 'groupBy' and activate it. + * It is added to the unique group of groupbys. + * @param {Object} field + */ + createNewGroupBy(field) { + const groupBy = Object.values(this.state.filters).find(f => f.type === 'groupBy'); + const filter = { + description: field.string || field.name, + fieldName: field.name, + fieldType: field.type, + groupId: groupBy ? groupBy.groupId : groupId++, + groupNumber, + id: filterId, + type: 'groupBy', + }; + this.state.filters[filterId] = filter; + if (['date', 'datetime'].includes(field.type)) { + filter.hasOptions = true; + filter.defaultOptionId = DEFAULT_INTERVAL; + this.toggleFilterWithOptions(filterId); + } else { + this.toggleFilter(filterId); + } + groupNumber++; + filterId++; + } + + /** + * Deactivate a group with provided groupId, i.e. delete the query elements + * with given groupId. + * @param {number} groupId + */ + deactivateGroup(groupId) { + this.state.query = this.state.query.filter( + queryElem => queryElem.groupId !== groupId + ); + this._checkComparisonStatus(); + } + + /** + * Delete a filter of type 'favorite' with given filterId server side and + * in control panel model. Of course the filter is also removed + * from the search query. + * @param {number} filterId + */ + async deleteFavorite(filterId) { + const { serverSideId } = this.state.filters[filterId]; + await this.env.dataManager.delete_filter(serverSideId); + const index = this.state.query.findIndex( + queryElem => queryElem.filterId === filterId + ); + delete this.state.filters[filterId]; + if (index >= 0) { + this.state.query.splice(index, 1); + } + } + + /** + * @returns {Object} + */ + getContext() { + const groups = this._getGroups(); + return this._getContext(groups); + } + + /** + * @returns {Array[]} + */ + 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)}` + ); + } + } + + /** + * @returns {string[]} + */ + 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; + return this._getTimeRanges(requireEvaluation); + } + + /** + * Used to call dispatch and trigger a 'search'. + */ + search() { + /* ... */ + } + + /** + * Activate/Deactivate a filter of type 'comparison' with provided id. + * At most one filter of type 'comparison' can be activated at every time. + * @param {string} filterId + */ + toggleComparison(filterId) { + const { groupId, dateFilterId } = this.state.filters[filterId]; + const queryElem = this.state.query.find(queryElem => + queryElem.type === 'comparison' && + queryElem.filterId === filterId + ); + // make sure only one comparison can be active + this.state.query = this.state.query.filter(queryElem => queryElem.type !== 'comparison'); + if (!queryElem) { + this.state.query.push({ groupId, filterId, dateFilterId, type: 'comparison', }); + } + } + + /** + * Activate or deactivate the simple filter with given filterId, i.e. + * add or remove a corresponding query element. + * @param {string} filterId + */ + toggleFilter(filterId) { + const index = this.state.query.findIndex( + queryElem => queryElem.filterId === filterId + ); + if (index >= 0) { + this.state.query.splice(index, 1); + } else { + const { groupId, type } = this.state.filters[filterId]; + if (type === 'favorite') { + this.state.query = []; + } + this.state.query.push({ groupId, filterId }); + } + } + + /** + * Used to toggle a query element { filterId, optionId, (groupId) }. + * This can impact the query in various form, e.g. add/remove other query elements + * in case the filter is of type 'filter'. + * @param {string} filterId + * @param {string} [optionId] + */ + toggleFilterWithOptions(filterId, optionId) { + const filter = this.state.filters[filterId]; + optionId = optionId || filter.defaultOptionId; + const option = this.optionGenerators.find(o => o.id === optionId); + + const index = this.state.query.findIndex( + queryElem => queryElem.filterId === filterId && queryElem.optionId === optionId + ); + + if (index >= 0) { + this.state.query.splice(index, 1); + if (filter.type === 'filter' && !yearSelected(this._getSelectedOptionIds(filterId))) { + // This is the case where optionId was the last option + // of type 'year' to be there before being removed above. + // Since other options of type 'month' or 'quarter' do + // not make sense without a year we deactivate all options. + this.state.query = this.state.query.filter( + queryElem => queryElem.filterId !== filterId + ); + } + } else { + this.state.query.push({ groupId: filter.groupId, filterId, optionId }); + if (filter.type === 'filter' && !yearSelected(this._getSelectedOptionIds(filterId))) { + // Here we add 'this_year' as options if no option of type + // year is already selected. + this.state.query.push({ + groupId: filter.groupId, + filterId, + optionId: option.defaultYearId, + }); + } + } + if (filter.type === 'filter') { + this._checkComparisonStatus(); + } + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Activate the default favorite (if any) or all default filters. + * @private + */ + _activateDefaultFilters() { + if (this.defaultFavoriteId) { + // Activate default favorite + this.toggleFilter(this.defaultFavoriteId); + } else { + // Activate default filters + Object.values(this.state.filters) + .filter((f) => f.isDefault && f.type !== 'favorite') + .sort((f1, f2) => (f1.defaultRank || 100) - (f2.defaultRank || 100)) + .forEach(f => { + if (f.hasOptions) { + this.toggleFilterWithOptions(f.id); + } else if (f.type === 'field') { + let { operator, label, value } = f.defaultAutocompleteValue; + this.addAutoCompletionValues({ + filterId: f.id, + value, + operator, + label, + }); + } else { + this.toggleFilter(f.id); + } + }); + } + } + + /** + * This function populates the 'filters' object at initialization. + * The filters come from: + * - config.archNodes (types 'comparison', 'filter', 'groupBy', 'field'), + * - config.dynamicFilters (type 'filter'), + * - config.favoriteFilters (type 'favorite'), + * - code itself (type 'timeRange') + * @private + */ + _addFilters() { + this._createGroupOfFavorites(); + this._createGroupOfFiltersFromArch(); + this._createGroupOfDynamicFilters(); + } + + /** + * If a comparison is active, check if it should become inactive. + * The comparison should become inactive if the corresponding date filter has become + * inactive. + * @private + */ + _checkComparisonStatus() { + const activeComparison = this.activeComparison; + if (!activeComparison) { + return; + } + const { dateFilterId } = activeComparison; + const dateFilterIsActive = this.state.query.some( + queryElem => queryElem.filterId === dateFilterId + ); + if (!dateFilterIsActive) { + this.state.query = this.state.query.filter( + queryElem => queryElem.type !== 'comparison' + ); + } + } + + /** + * Returns the active comparison timeRanges object. + * @private + * @param {Object} comparisonFilter + * @returns {Object | null} + */ + _computeTimeRanges(comparisonFilter) { + const { filterId } = this.activeComparison; + if (filterId !== comparisonFilter.id) { + return null; + } + const { dateFilterId, comparisonOptionId } = comparisonFilter; + const { + fieldName, + fieldType, + description: dateFilterDescription, + } = this.state.filters[dateFilterId]; + + const selectedOptionIds = this._getSelectedOptionIds(dateFilterId); + + // compute range and range description + const { domain: range, description: rangeDescription } = constructDateDomain( + this.referenceMoment, fieldName, fieldType, selectedOptionIds, + ); + + // compute comparisonRange and comparisonRange description + const { + domain: comparisonRange, + description: comparisonRangeDescription, + } = constructDateDomain( + this.referenceMoment, fieldName, fieldType, selectedOptionIds, comparisonOptionId + ); + + return { + comparisonId: comparisonOptionId, + fieldName, + fieldDescription: dateFilterDescription, + range, + rangeDescription, + comparisonRange, + comparisonRangeDescription, + }; + } + + /** + * Starting from the array of date filters, create the filters of type + * 'comparison'. + * @private + * @param {Object[]} dateFilters + */ + _createGroupOfComparisons(dateFilters) { + const preFilters = []; + for (const dateFilter of dateFilters) { + for (const comparisonOption of this.comparisonOptions) { + const { id: dateFilterId, description } = dateFilter; + const preFilter = { + type: 'comparison', + comparisonOptionId: comparisonOption.id, + description: `${description}: ${comparisonOption.description}`, + dateFilterId, + }; + preFilters.push(preFilter); + } + } + this._createGroupOfFilters(preFilters); + } + + /** + * Add filters of type 'filter' determined by the key array dynamicFilters. + * @private + */ + _createGroupOfDynamicFilters() { + const dynamicFilters = this.config.dynamicFilters || []; + const pregroup = dynamicFilters.map(filter => { + return { + description: filter.description, + domain: JSON.stringify(filter.domain), + isDefault: true, + type: 'filter', + }; + }); + this._createGroupOfFilters(pregroup); + } + + /** + * 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 (activateFavorite && favorite.isDefault) { + this.defaultFavoriteId = favorite.id; + } + }); + } + + /** + * Using a list (a 'pregroup') of 'prefilters', create new filters in `state.filters` + * for each prefilter. The new filters belong to a same new group. + * @private + * @param {Object[]} pregroup, list of 'prefilters' + * @param {string} type + */ + _createGroupOfFilters(pregroup) { + pregroup.forEach(preFilter => { + const filter = Object.assign(preFilter, { groupId, id: filterId }); + this.state.filters[filterId] = filter; + if (!this.defaultFavoriteId && filter.isDefault && filter.type === 'field') { + this._prepareDefaultLabel(filter); + } + filterId++; + }); + groupId++; + } + + /** + * Parse the arch of a 'search' view and create corresponding filters and groups. + * + * A searchview arch may contain a 'searchpanel' node, but this isn't + * the concern of the ControlPanel (the SearchPanel will handle it). + * Ideally, this code should whitelist the tags to take into account + * instead of blacklisting the others, but with the current (messy) + * structure of a searchview arch, it's way simpler to do it that way. + * @private + */ + _createGroupOfFiltersFromArch() { + const preFilters = this.config.archNodes.reduce( + (preFilters, child) => { + if (child.tag === 'group') { + return [...preFilters, ...child.children.map(c => this._evalArchChild(c))]; + } else { + return [...preFilters, this._evalArchChild(child)]; + } + }, + [] + ); + preFilters.push({ tag: 'separator' }); + + // create groups and filters + let currentTag; + let currentGroup = []; + let pregroupOfGroupBys = []; + + preFilters.forEach(preFilter => { + if ( + preFilter.tag !== currentTag || + ['separator', 'field'].includes(preFilter.tag) + ) { + if (currentGroup.length) { + if (currentTag === 'groupBy') { + pregroupOfGroupBys = [...pregroupOfGroupBys, ...currentGroup]; + } else { + this._createGroupOfFilters(currentGroup); + } + } + currentTag = preFilter.tag; + currentGroup = []; + groupNumber++; + } + if (preFilter.tag !== 'separator') { + const filter = { + type: preFilter.tag, + // we need to codify here what we want to keep from attrs + // and how, for now I put everything. + // In some sence, some filter are active (totally determined, given) + // and others are passive (require input(s) to become determined) + // What is the right place to process the attrs? + }; + if (filter.type === 'filter' || filter.type === 'groupBy') { + filter.groupNumber = groupNumber; + } + this._extractAttributes(filter, preFilter.attrs); + currentGroup.push(filter); + } + }); + + if (pregroupOfGroupBys.length) { + this._createGroupOfFilters(pregroupOfGroupBys); + } + const dateFilters = Object.values(this.state.filters).filter( + (filter) => filter.isDateFilter + ); + if (dateFilters.length) { + this._createGroupOfComparisons(dateFilters); + } + } + + /** + * Returns null or a copy of the provided filter with additional information + * used only outside of the control panel model, like in search bar or in the + * various menus. The value null is returned if the filter should not appear + * for some reason. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @returns {Object | null} + */ + _enrichFilterCopy(filter, filterQueryElements) { + const isActive = Boolean(filterQueryElements.length); + const f = Object.assign({ isActive }, filter); + + function _enrichOptions(options) { + return options.map(o => { + const { description, id, groupNumber } = o; + const isActive = filterQueryElements.some(a => a.optionId === id); + return { description, id, groupNumber, isActive }; + }); + } + + switch (f.type) { + case 'comparison': { + const { dateFilterId } = filter; + const dateFilterIsActive = this.state.query.some( + queryElem => queryElem.filterId === dateFilterId + ); + if (!dateFilterIsActive) { + return null; + } + break; + } + case 'filter': + if (f.hasOptions) { + f.options = _enrichOptions(this.optionGenerators); + } + break; + case 'groupBy': + if (f.hasOptions) { + f.options = _enrichOptions(this.intervalOptions); + } + break; + case 'field': + f.autoCompleteValues = filterQueryElements.map( + ({ label, value, operator }) => ({ label, value, operator }) + ); + break; + } + return f; + } + + /** + * Process a given arch node and enrich it. + * @private + * @param {Object} child + * @returns {Object} + */ + _evalArchChild(child) { + if (child.attrs.context) { + try { + const context = pyUtils.eval('context', child.attrs.context); + child.attrs.context = context; + if (context.group_by) { + // let us extract basic data since we just evaluated context + // and use a correct tag! + child.attrs.fieldName = context.group_by.split(':')[0]; + child.attrs.defaultInterval = context.group_by.split(':')[1]; + child.tag = 'groupBy'; + } + } catch (e) { } + } + if (this.searchDefaults.hasOwnProperty(child.attrs.name)) { + child.attrs.isDefault = true; + let value = this.searchDefaults[child.attrs.name]; + if (child.tag === 'field') { + if (value instanceof Array) { + value = value[0]; + } + child.attrs.defaultAutocompleteValue = { value, operator: '=' }; + } else if (child.tag === 'groupBy') { + child.attrs.defaultRank = typeof value === 'number' ? value : 100; + } + } + return child; + } + + /** + * Process the attributes set on an arch node and adds various keys to + * the given filter. + * @private + * @param {Object} filter + * @param {Object} attrs + */ + _extractAttributes(filter, attrs) { + if (attrs.isDefault) { + 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) { + filter.context = attrs.context; + } + if (attrs.date) { + filter.isDateFilter = true; + filter.hasOptions = true; + filter.fieldName = attrs.date; + filter.fieldType = this.fields[attrs.date].type; + filter.defaultOptionId = attrs.default_period || DEFAULT_PERIOD; + } else { + filter.domain = attrs.domain || '[]'; + } + if (filter.isDefault) { + filter.defaultRank = -5; + } + break; + case 'groupBy': + filter.fieldName = attrs.fieldName; + filter.fieldType = this.fields[attrs.fieldName].type; + if (['date', 'datetime'].includes(filter.fieldType)) { + filter.hasOptions = true; + filter.defaultOptionId = attrs.defaultInterval || DEFAULT_INTERVAL; + } + if (filter.isDefault) { + filter.defaultRank = attrs.defaultRank; + } + break; + case 'field': { + const field = this.fields[attrs.name]; + filter.fieldName = attrs.name; + filter.fieldType = field.type; + if (attrs.domain) { + filter.domain = attrs.domain; + } + if (attrs.filter_domain) { + filter.filterDomain = attrs.filter_domain; + } else if (attrs.operator) { + filter.operator = attrs.operator; + } + if (attrs.context) { + filter.context = attrs.context; + } + if (filter.isDefault) { + filter.defaultRank = -10; + filter.defaultAutocompleteValue = attrs.defaultAutocompleteValue; + } + if (attrs.widget) { + filter.widget = attrs.widget; + } + break; + } + } + if (filter.fieldName && !attrs.string) { + const { string } = this.fields[filter.fieldName]; + filter.description = string; + } + } + + /** + * Returns an object irFilter serving to create an ir_filte in db + * starting from a filter of type 'favorite'. + * @private + * @param {Object} favorite + * @returns {Object} + */ + _favoriteToIrFilter(favorite) { + const irFilter = { + action_id: this.config.actionId, + model_id: this.config.modelName, + }; + + // ir.filter fields + if ('description' in favorite) { + irFilter.name = favorite.description; + } + if ('domain' in favorite) { + irFilter.domain = favorite.domain; + } + if ('isDefault' in favorite) { + irFilter.is_default = favorite.isDefault; + } + if ('orderedBy' in favorite) { + const sort = favorite.orderedBy.map( + ob => ob.name + (ob.asc === false ? " desc" : "") + ); + irFilter.sort = JSON.stringify(sort); + } + if ('serverSideId' in favorite) { + irFilter.id = favorite.serverSideId; + } + if ('userId' in favorite) { + irFilter.user_id = favorite.userId; + } + + // Context + const context = Object.assign({}, favorite.context); + if ('groupBys' in favorite) { + context.group_by = favorite.groupBys; + } + if ('comparison' in favorite) { + context.comparison = favorite.comparison; + } + if (Object.keys(context).length) { + irFilter.context = context; + } + + return irFilter; + } + + /** + * Return the domain resulting from the combination of the auto-completion + * values of a filter of type 'field'. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @returns {string} + */ + _getAutoCompletionFilterDomain(filter, filterQueryElements) { + const domains = filterQueryElements.map(({ label, value, operator }) => { + let domain; + if (filter.filterDomain) { + domain = Domain.prototype.stringToArray( + filter.filterDomain, + { + self: label, + raw_value: value, + } + ); + } else { + // Create new domain + domain = [[filter.fieldName, operator, value]]; + } + return Domain.prototype.arrayToString(domain); + }); + return pyUtils.assembleDomains(domains, 'OR'); + } + + /** + * Construct a single context from the contexts of + * filters of type 'filter', 'favorite', and 'field'. + * @private + * @returns {Object} + */ + _getContext(groups) { + const types = ['filter', 'favorite', 'field']; + const contexts = groups.reduce( + (contexts, group) => { + if (types.includes(group.type)) { + contexts.push(...this._getGroupContexts(group)); + } + return contexts; + }, + [] + ); + const evaluationContext = this.env.session.user_context; + try { + return pyUtils.eval('contexts', contexts, evaluationContext); + } catch (err) { + throw new Error( + this.env._t("Failed to evaluate search context") + ":\n" + + JSON.stringify(err) + ); + } + } + + /** + * Compute the string representation or the description of the current domain associated + * with a date filter starting from its corresponding query elements. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @param {'domain'|'description'} [key='domain'] + * @returns {string} + */ + _getDateFilterDomain(filter, filterQueryElements, key = 'domain') { + const { fieldName, fieldType } = filter; + const selectedOptionIds = filterQueryElements.map(queryElem => queryElem.optionId); + const dateFilterRange = constructDateDomain( + this.referenceMoment, fieldName, fieldType, selectedOptionIds, + ); + return dateFilterRange[key]; + } + + /** + * Return the string or array representation of a domain created by combining + * appropriately (with an 'AND') the domains coming from the active groups + * of type 'filter', 'favorite', and 'field'. + * @private + * @param {Object[]} groups + * @returns {string} + */ + _getDomain(groups) { + const types = ['filter', 'favorite', 'field']; + const domains = []; + for (const group of groups) { + if (types.includes(group.type)) { + domains.push(this._getGroupDomain(group)); + } + } + return pyUtils.assembleDomains(domains, 'AND'); + } + + /** + * 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; + } + + /** + * @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; + } + + /** + * Return the context of the provided (active) filter. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @returns {Object} + */ + _getFilterContext(filter, filterQueryElements) { + let context = filter.context || {}; + // for nodes, a dynamic context (like context="{'field1': self}") + // should set {'field1': [value1, value2]} in the context + if (filter.type === 'field' && filter.context) { + context = pyUtils.eval('context', + filter.context, + { self: filterQueryElements.map(({ value }) => value) }, + ); + } + // the following code aims to remodel this: + // https://github.com/odoo/odoo/blob/12.0/addons/web/static/src/js/views/search/search_inputs.js#L498 + // this is required for the helpdesk tour to pass + // this seems weird to only do that for m2o fields, but a test fails if + // we do it for other fields (my guess being that the test should simply + // be adapted) + if (filter.type === 'field' && filter.isDefault && filter.fieldType === 'many2one') { + context[`default_${filter.fieldName}`] = filter.defaultAutocompleteValue.value; + } + return context; + } + + /** + * Return the domain of the provided filter. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @returns {string} domain, string representation of a domain + */ + _getFilterDomain(filter, filterQueryElements) { + if (filter.type === 'filter' && filter.hasOptions) { + const { dateFilterId } = this.activeComparison || {}; + if (this.searchMenuTypes.includes('comparison') && dateFilterId === filter.id) { + return "[]"; + } + return this._getDateFilterDomain(filter, filterQueryElements); + } else if (filter.type === 'field') { + return this._getAutoCompletionFilterDomain(filter, filterQueryElements); + } + return filter.domain; + } + + /** + * Return the groupBys of the provided filter. + * @private + * @param {Object} filter + * @param {Object[]} filterQueryElements + * @returns {string[]} groupBys + */ + _getFilterGroupBys(filter, filterQueryElements) { + if (filter.type === 'groupBy') { + const fieldName = filter.fieldName; + if (filter.hasOptions) { + return filterQueryElements.map( + ({ optionId }) => `${fieldName}:${optionId}` + ); + } else { + return [fieldName]; + } + } else { + return filter.groupBys; + } + } + + /** + * Return the concatenation of groupBys comming from the active filters of + * type 'favorite' and 'groupBy'. + * The result respects the appropriate logic: the groupBys + * coming from an active favorite (if any) come first, then come the + * groupBys comming from the active filters of type 'groupBy' in the order + * defined in this.state.query. If no groupBys are found, one tries to + * find some grouBys in the action context. + * @private + * @param {Object[]} groups + * @returns {string[]} + */ + _getGroupBy(groups) { + const groupBys = groups.reduce( + (groupBys, group) => { + if (['groupBy', 'favorite'].includes(group.type)) { + groupBys.push(...this._getGroupGroupBys(group)); + } + return groupBys; + }, + [] + ); + const groupBy = groupBys.length ? groupBys : (this.actionContext.group_by || []); + return typeof groupBy === 'string' ? [groupBy] : groupBy; + } + + /** + * Return the list of the contexts of the filters active in the given + * group. + * @private + * @param {Object} group + * @returns {Object[]} + */ + _getGroupContexts(group) { + const contexts = group.activities.reduce( + (ctx, qe) => [...ctx, this._getFilterContext(qe.filter, qe.filterQueryElements)], + [] + ); + return contexts; + } + + /** + * Return the string representation of a domain created by combining + * appropriately (with an 'OR') the domains coming from the filters + * active in the given group. + * @private + * @param {Object} group + * @returns {string} string representation of a domain + */ + _getGroupDomain(group) { + const domains = group.activities.map(({ filter, filterQueryElements }) => { + return this._getFilterDomain(filter, filterQueryElements); + }); + return pyUtils.assembleDomains(domains, 'OR'); + } + + /** + * Return the groupBys coming form the filters active in the given group. + * @private + * @param {Object} group + * @returns {string[]} + */ + _getGroupGroupBys(group) { + const groupBys = group.activities.reduce( + (gb, qe) => [...gb, ...this._getFilterGroupBys(qe.filter, qe.filterQueryElements)], + [] + ); + return groupBys; + } + + /** + * Reconstruct the (active) groups from the query elements. + * @private + * @returns {Object[]} + */ + _getGroups() { + const groups = this.state.query.reduce( + (groups, queryElem) => { + const { groupId, filterId } = queryElem; + let group = groups.find(group => group.id === groupId); + const filter = this.state.filters[filterId]; + if (!group) { + const { type } = filter; + group = { + id: groupId, + type, + activities: [] + }; + groups.push(group); + } + group.activities.push(queryElem); + return groups; + }, + [] + ); + groups.forEach(g => this._mergeActivities(g)); + return groups; + } + + /** + * Used to get the key orderedBy of the active favorite. + * @private + * @param {Object[]} groups + * @returns {string[]} orderedBy + */ + _getOrderedBy(groups) { + return groups.reduce( + (orderedBy, group) => { + if (group.type === 'favorite') { + const favoriteOrderedBy = group.activities[0].filter.orderedBy; + if (favoriteOrderedBy) { + // Group order is reversed but inner order is kept + orderedBy = [...favoriteOrderedBy, ...orderedBy]; + } + } + return orderedBy; + }, + [] + ); + } + + /** + * Starting from the id of a date filter, returns the array of option ids currently selected + * for the corresponding filter. + * @private + * @param {string} dateFilterId + * @returns {string[]} + */ + _getSelectedOptionIds(dateFilterId) { + const selectedOptionIds = []; + for (const queryElem of this.state.query) { + if (queryElem.filterId === dateFilterId) { + selectedOptionIds.push(queryElem.optionId); + } + } + 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 + * @param {Object} irFilter + * @returns {Object} + */ + _irFilterToFavorite(irFilter) { + let userId = irFilter.user_id || false; + if (Array.isArray(userId)) { + userId = userId[0]; + } + const groupNumber = userId ? FAVORITE_PRIVATE_GROUP : FAVORITE_SHARED_GROUP; + const context = pyUtils.eval('context', irFilter.context, this.env.session.user_context); + let groupBys = []; + if (context.group_by) { + groupBys = context.group_by; + delete context.group_by; + } + let comparison; + if (context.comparison) { + comparison = context.comparison; + delete context.comparison; + } + let sort; + try { + sort = JSON.parse(irFilter.sort); + } catch (err) { + if (err instanceof SyntaxError) { + sort = []; + } else { + throw err; + } + } + const orderedBy = sort.map(order => { + let fieldName; + let asc; + const sqlNotation = order.split(' '); + if (sqlNotation.length > 1) { + // regex: \fieldName (asc|desc)?\ + fieldName = sqlNotation[0]; + asc = sqlNotation[1] === 'asc'; + } else { + // legacy notation -- regex: \-?fieldName\ + fieldName = order[0] === '-' ? order.slice(1) : order; + asc = order[0] === '-' ? false : true; + } + return { + asc: asc, + name: fieldName, + }; + }); + const favorite = { + context, + description: irFilter.name, + domain: irFilter.domain, + groupBys, + groupNumber, + orderedBy, + removable: true, + serverSideId: irFilter.id, + type: 'favorite', + userId, + }; + if (irFilter.is_default) { + favorite.isDefault = irFilter.is_default; + } + if (comparison) { + favorite.comparison = comparison; + } + return favorite; + } + + /** + * Group the query elements in group.activities by qe -> qe.filterId + * and changes the form of group.activities to make it more suitable for further + * computations. + * @private + * @param {Object} group + */ + _mergeActivities(group) { + const { activities, type } = group; + let res = []; + switch (type) { + case 'filter': + case 'groupBy': { + for (const activity of activities) { + const { filterId } = activity; + let a = res.find(({ filter }) => filter.id === filterId); + if (!a) { + a = { + filter: this.state.filters[filterId], + filterQueryElements: [] + }; + res.push(a); + } + a.filterQueryElements.push(activity); + } + break; + } + case 'favorite': + case 'field': + case 'comparison': { + // all activities in the group have same filterId + const { filterId } = group.activities[0]; + const filter = this.state.filters[filterId]; + res.push({ + filter, + filterQueryElements: group.activities + }); + break; + } + } + if (type === 'groupBy') { + res.forEach(activity => { + activity.filterQueryElements.sort( + (qe1, qe2) => rankInterval(qe1.optionId) - rankInterval(qe2.optionId) + ); + }); + } + group.activities = res; + } + + /** + * Set the key label in defaultAutocompleteValue used by default filters of + * type 'field'. + * @private + * @param {Object} filter + */ + _prepareDefaultLabel(filter) { + const { id, fieldType, fieldName, defaultAutocompleteValue } = filter; + const { selection, context, relation } = this.fields[fieldName]; + if (fieldType === 'selection') { + defaultAutocompleteValue.label = selection.find( + sel => sel[0] === defaultAutocompleteValue.value + )[1]; + } else if (fieldType === 'many2one') { + const updateLabel = label => { + const queryElem = this.state.query.find(({ filterId }) => filterId === id); + if (queryElem) { + queryElem.label = label; + defaultAutocompleteValue.label = label; + } + }; + const promise = this.env.services.rpc({ + args: [defaultAutocompleteValue.value], + context: context, + method: 'name_get', + model: relation, + }) + .then(results => updateLabel(results[0][1])) + .guardedCatch(() => updateLabel(defaultAutocompleteValue.value)); + this.labelPromises.push(promise); + } else { + defaultAutocompleteValue.label = defaultAutocompleteValue.value; + } + } + + /** + * 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. + * If the operation is successful, a new filter of type 'favorite' is + * created and activated. + * @private + * @param {Object} preFilter + * @returns {Promise} + */ + async _saveQuery(preFilter) { + const groups = this._getGroups(); + + const userContext = this.env.session.user_context; + let controllerQueryParams; + this.config.trigger("get-controller-query-params", params => { + controllerQueryParams = params; + }); + controllerQueryParams = controllerQueryParams || {}; + controllerQueryParams.context = controllerQueryParams.context || {}; + + const queryContext = this._getContext(groups); + const context = pyUtils.eval( + 'contexts', + [userContext, controllerQueryParams.context, queryContext] + ); + for (const key in userContext) { + delete context[key]; + } + + const requireEvaluation = false; + const domain = this._getDomain(groups); + const groupBys = this._getGroupBy(groups); + const timeRanges = this._getTimeRanges(requireEvaluation); + const orderedBy = controllerQueryParams.orderedBy ? + controllerQueryParams.orderedBy : + (this._getOrderedBy(groups) || []); + + const userId = preFilter.isShared ? false : this.env.session.uid; + delete preFilter.isShared; + + Object.assign(preFilter, { + context, + domain, + groupBys, + groupNumber: userId ? FAVORITE_PRIVATE_GROUP : FAVORITE_SHARED_GROUP, + orderedBy, + removable: true, + userId, + }); + if (timeRanges) { + preFilter.comparison = timeRanges; + } + const irFilter = this._favoriteToIrFilter(preFilter); + const serverSideId = await this.env.dataManager.create_filter(irFilter); + + preFilter.serverSideId = serverSideId; + + return preFilter; + } + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @override + * @returns {{ attrs: Object, children: Object[] }} + */ + static extractArchInfo(archs) { + const { attrs, children } = archs.search; + const controlPanelInfo = { + attrs, + children: [], + }; + for (const child of children) { + if (child.tag !== "searchpanel") { + controlPanelInfo.children.push(child); + } + } + return controlPanelInfo; + } + } + + 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,256 @@ -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; + const isNotNull = (val) => val !== null && val !== undefined; - // 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; - } + /** + * Feature extension of the class Model. + * @see {Model} + */ + class ModelExtension { + /** + * @param {Object} config + * @param {Object} config.env + */ + constructor(config) { + this.config = config; + this.env = this.config.env; + this._shouldLoad = true; + this.state = {}; + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * Used by the parent model to initiate a load action. The actual + * loading of the extension is determined by the "_shouldLoad" property. + */ + async callLoad() { + if (this._shouldLoad) { + this._shouldLoad = false; + await this.load(); } - if (!lastGroup) { - lastGroup = [cur]; - acc.push(lastGroup); + } + + /** + * 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); } - lastValue = curVal; - return acc; - }, []); + } + + /** + * 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 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. + * 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 { - - constructor() { + /** + * 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; - this.mapping = {}; // could be a weak map + + 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); } //--------------------------------------------------------------------- @@ -55,23 +258,133 @@ 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} + * 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. */ - 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(); + 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; +}); -- cgit v1.2.3