diff options
Diffstat (limited to 'addons/web/static/src/js')
9 files changed, 759 insertions, 590 deletions
diff --git a/addons/web/static/src/js/control_panel/comparison_menu.js b/addons/web/static/src/js/control_panel/comparison_menu.js new file mode 100644 index 00000000000..4504370e7c4 --- /dev/null +++ b/addons/web/static/src/js/control_panel/comparison_menu.js @@ -0,0 +1,63 @@ +odoo.define("web.ComparisonMenu", function (require) { + "use strict"; + + const DropdownMenu = require("web.DropdownMenu"); + const { FACET_ICONS } = require("web.searchUtils"); + const { useModel } = require("web.model"); + + /** + * "Comparison" menu + * + * Displays a set of comparison options related to the currently selected + * date filters. + * @extends DropdownMenu + */ + class ComparisonMenu extends DropdownMenu { + constructor() { + super(...arguments); + this.model = useModel("controlPanelModel"); + } + + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @override + */ + get icon() { + return FACET_ICONS.comparison; + } + + /** + * @override + */ + get items() { + return this.model.getFiltersOfType("comparison"); + } + + /** + * @override + */ + get title() { + return this.env._t("Comparison"); + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @private + * @param {OwlEvent} ev + */ + _onItemSelected(ev) { + ev.stopPropagation(); + const { item } = ev.detail; + this.model.dispatch("toggleComparison", item.id); + } + + } + + return ComparisonMenu; +}); diff --git a/addons/web/static/src/js/control_panel/control_panel.js b/addons/web/static/src/js/control_panel/control_panel.js index 5d42cfc2fbe..554912175bb 100644 --- a/addons/web/static/src/js/control_panel/control_panel.js +++ b/addons/web/static/src/js/control_panel/control_panel.js @@ -3,13 +3,13 @@ odoo.define('web.ControlPanel', function (require) { const ActionMenus = require('web.ActionMenus'); const ControlPanelModel = require('web.ControlPanelModel'); + const ComparisonMenu = require('web.ComparisonMenu'); const FavoriteMenu = require('web.FavoriteMenu'); const FilterMenu = require('web.FilterMenu'); const GroupByMenu = require('web.GroupByMenu'); const patchMixin = require('web.patchMixin'); const Pager = require('web.Pager'); const SearchBar = require('web.SearchBar'); - const TimeRangeMenu = require('web.TimeRangeMenu'); const { useModel } = require('web.model'); const { Component, hooks } = owl; @@ -191,7 +191,7 @@ odoo.define('web.ControlPanel', function (require) { ControlPanel.components = { SearchBar, ActionMenus, Pager, - FilterMenu, GroupByMenu, TimeRangeMenu, FavoriteMenu, + ComparisonMenu, FilterMenu, GroupByMenu, FavoriteMenu, }; ControlPanel.defaultProps = { breadcrumbs: [], 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 index 034c4cd22c7..e1eb9c8da1a 100644 --- a/addons/web/static/src/js/control_panel/control_panel_model.js +++ b/addons/web/static/src/js/control_panel/control_panel_model.js @@ -6,11 +6,9 @@ odoo.define('web.ControlPanelModel', function (require) { const { parseArch } = require('web.viewUtils'); const pyUtils = require('web.py_utils'); - const { COMPARISON_TIME_RANGE_OPTIONS, - DEFAULT_INTERVAL, DEFAULT_PERIOD, FACET_ICONS, - INTERVAL_OPTIONS, OPTION_GENERATORS, - TIME_RANGE_OPTIONS, YEAR_OPTIONS, - rankPeriod, rankInterval } = require('web.searchUtils'); + 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; @@ -41,10 +39,15 @@ odoo.define('web.ControlPanelModel', function (require) { * @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'|'timeRange'|'field'|'favorite' + * @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 @@ -53,15 +56,15 @@ odoo.define('web.ControlPanelModel', function (require) { * @if isDefault = true: * > @prop {number} [defaultRank=-5] used to determine the order of * > activation of default filters - * @prop {boolean} [hasOptions] true if the filter comes from an arch node + * @prop {boolean} [isDateFilter] true if the filter comes from an arch node * with a valid 'date' attribute. - * @if hasOptions = true + * @if isDateFilter = true + * > @prop {boolean} [hasOptions=true] * > @prop {string} defaultOptionId option identifier determined by - * > default_period attribute (@see OPTION_GENERATORS). + * > 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 - * > @prop {Objecŧ[]} basicDomains of the form { description, domain }[] * @else * > @prop {string} domain * @@ -79,9 +82,6 @@ odoo.define('web.ControlPanelModel', function (require) { * > @prop {string} defaultOptionId option identifier (see INTERVAL_OPTIONS) * default set to DEFAULT_INTERVAL. * - * • type 'timeRange': - * no extra key, a single filter has that type - * * • type 'field': * @prop {string} fieldName * @prop {string} fieldType @@ -97,6 +97,8 @@ odoo.define('web.ControlPanelModel', function (require) { * > @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 @@ -106,7 +108,6 @@ odoo.define('web.ControlPanelModel', function (require) { * @prop {number} serverSideId * @prop {number} userId * @prop {boolean} [isDefault] - * @prop {Object} [timeRanges] of the form { fieldName, rangeId[, comparisonRangeId] } * *------------------------------------------------------------------------- * Query @@ -120,17 +121,17 @@ odoo.define('web.ControlPanelModel', function (require) { * * 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 OPTION_GENERATORS) + * @prop {string} optionId option identifier (@see PERIOD_OPTIONS) * * • type 'groupBy' with hasOptions=true: * @prop {string} optionId option identifier (@see INTERVAL_OPTIONS) * - * • type 'timeRange': - * @prop {string} fieldName name of a field - * @prop {string} rangeId option identifier (see TIME_RANGE_OPTIONS) - * @prop {string} [comparisonRangeId] option identifier (see COMPARISON_TIME_RANGE_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 @@ -173,6 +174,17 @@ odoo.define('web.ControlPanelModel', function (require) { } //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + /** + * @returns {(Object | undefined)} + */ + get activeComparison() { + return this.state.query.find(queryElem => queryElem.type === 'comparison'); + } + + //--------------------------------------------------------------------- // Public //--------------------------------------------------------------------- @@ -196,30 +208,6 @@ odoo.define('web.ControlPanelModel', function (require) { } /** - * Activate the unique filter of type 'timeRange' with provided 'options' fieldName, rangeId, - * and optional comparisonRangeId. - * @param {string} fieldName - * @param {string} rangeId - * @param {string} [comparisonRangeId] - */ - activateTimeRange(fieldName, rangeId, comparisonRangeId) { - const filter = Object.values(this.state.filters).find(f => f.type === 'timeRange'); - const detail = { fieldName, rangeId }; - if (comparisonRangeId) { - detail.comparisonRangeId = comparisonRangeId; - } - const queryElem = this.state.query.find(queryElem => queryElem.filterId === filter.id); - if (queryElem) { - Object.assign(queryElem, detail); - if (!comparisonRangeId) { - delete queryElem.comparisonRangeId; - } - } else { - this.state.query.push(Object.assign({ groupId: filter.groupId, filterId: filter.id }, detail)); - } - } - - /** * Remove all the query elements from query. */ clearQuery() { @@ -311,6 +299,7 @@ odoo.define('web.ControlPanelModel', function (require) { this.state.query = this.state.query.filter( queryElem => queryElem.groupId !== groupId ); + this._checkComparisonStatus(); } /** @@ -347,7 +336,7 @@ odoo.define('web.ControlPanelModel', function (require) { */ getFacets() { const accept = type => { - if (type === 'groupBy' && !this.searchMenuTypes.includes('groupBy')) { + if (['groupBy', 'comparison'].includes(type) && !this.searchMenuTypes.includes(type)) { return false; } return true; @@ -384,11 +373,13 @@ odoo.define('web.ControlPanelModel', function (require) { const filters = Object.values(this.state.filters).reduce( (filters, filter) => { if (filter.type === type && !filter.invisible) { - const activities = this.state.query.filter( + const filterQueryElements = this.state.query.filter( queryElem => queryElem.filterId === filter.id ); - const enrichedFilter = this._enrichFilterCopy(filter, activities); - filters.push(enrichedFilter); + const enrichedFilter = this._enrichFilterCopy(filter, filterQueryElements); + if (enrichedFilter) { + filters.push(enrichedFilter); + } } return filters; }, @@ -419,7 +410,7 @@ odoo.define('web.ControlPanelModel', function (require) { } else { query.groupBy = []; } - if (this.searchMenuTypes.includes('timeRange')) { + if (this.searchMenuTypes.includes('comparison')) { const timeRanges = this._getTimeRanges(requireEvaluation); query.timeRanges = timeRanges || {}; } @@ -442,6 +433,24 @@ odoo.define('web.ControlPanelModel', function (require) { 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 @@ -471,22 +480,15 @@ odoo.define('web.ControlPanelModel', function (require) { toggleFilterWithOptions(filterId, optionId) { const filter = this.state.filters[filterId]; optionId = optionId || filter.defaultOptionId; - - const noYearSelected = (filterId) => !this.state.query.some(queryElem => { - return queryElem.filterId === filterId && YEAR_OPTIONS[queryElem.optionId]; - }); - - const defaultYearId = (optionId) => { - const year = this.optionGenerators.find(o => o.id === optionId).defaultYear; - return this.optionGenerators.find(o => o.setParam.year === year).id; - }; + 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' && noYearSelected(filterId)) { + 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 @@ -497,16 +499,19 @@ odoo.define('web.ControlPanelModel', function (require) { } } else { this.state.query.push({ groupId: filter.groupId, filterId, optionId }); - if (filter.type === 'filter' && noYearSelected(filterId)) { + 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: defaultYearId(optionId), + optionId: option.defaultYearId, }); } } + if (filter.type === 'filter') { + this._checkComparisonStatus(); + } } /** @@ -535,16 +540,6 @@ odoo.define('web.ControlPanelModel', function (require) { //--------------------------------------------------------------------- /** - * Activate the filter of type timeRange with the options set in the key - * time_ranges of actionContext (if any). - * @private - */ - _activateDefaultTimeRanges() { - const { field, range, comparisonRange } = this.actionContext.time_ranges; - this.activateTimeRange(field, range, comparisonRange); - } - - /** * Activate the default favorite (if any) or all default filters. * @private */ @@ -583,25 +578,109 @@ odoo.define('web.ControlPanelModel', function (require) { } }); } - if (this.actionContext.time_ranges) { - this._activateDefaultTimeRanges(); - } } /** * This function populates the 'filters' object at initialization. * The filters come from: - * - config.viewInfo.arch (types 'filter', 'groupBy', 'field'), + * - config.viewInfo.arch (types 'comparison', 'filter', 'groupBy', 'field'), * - config.dynamicFilters (type 'filter'), * - config.viewInfo.favoriteFilters (type 'favorite'), - * - code itself (type 'timeRange') * @private */ _addFilters() { this._createGroupOfFiltersFromArch(); this._createGroupOfDynamicFilters(); this._createGroupOfFavorites(); - this._createGroupOfTimeRanges(); + } + + /** + * 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); } /** @@ -717,15 +796,12 @@ odoo.define('web.ControlPanelModel', function (require) { if (pregroupOfGroupBys.length) { this._createGroupOfFilters(pregroupOfGroupBys); } - } - - /** - * Add a single filter of type 'timeRange' to filters. - * @private - */ - _createGroupOfTimeRanges() { - const pregroup = [{ type: 'timeRange' }]; - this._createGroupOfFilters(pregroup); + const dateFilters = Object.values(this.state.filters).filter( + (filter) => filter.isDateFilter + ); + if (dateFilters.length) { + this._createGroupOfComparisons(dateFilters); + } } /** @@ -734,19 +810,21 @@ odoo.define('web.ControlPanelModel', function (require) { * 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 a copy of the provided filter with additional information + * 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. + * 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} + * @returns {Object|undefined} */ _enrichFilterCopy(filter, filterQueryElements) { const isActive = Boolean(filterQueryElements.length); @@ -761,6 +839,16 @@ odoo.define('web.ControlPanelModel', function (require) { } 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); @@ -776,12 +864,6 @@ odoo.define('web.ControlPanelModel', function (require) { ({ label, value, operator }) => ({ label, value, operator }) ); break; - case 'timeRange': - if (filterQueryElements.length) { - const timeRange = this._extractTimeRange(filterQueryElements[0]); - Object.assign(f, timeRange); - } - break; } return f; } @@ -842,11 +924,11 @@ odoo.define('web.ControlPanelModel', function (require) { 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; - filter.basicDomains = this._getDateFilterBasicDomains(filter); } else { filter.domain = attrs.domain || '[]'; } @@ -865,7 +947,7 @@ odoo.define('web.ControlPanelModel', function (require) { filter.defaultRank = attrs.defaultRank; } break; - case 'field': + case 'field': { const field = this.fields[attrs.name]; filter.fieldName = attrs.name; filter.fieldType = field.type; @@ -885,6 +967,7 @@ odoo.define('web.ControlPanelModel', function (require) { filter.defaultAutocompleteValue = attrs.defaultAutocompleteValue; } break; + } } if (filter.fieldName && !attrs.string) { const { string } = this.fields[filter.fieldName]; @@ -893,76 +976,6 @@ odoo.define('web.ControlPanelModel', function (require) { } /** - * For filter of type 'filter' with hasOptions=true, - * returns an array of domains or descriptions according to key. - * @param {Object} filter - * @param {Object[]} filterQueryElements - * @param {'domain'|'description'} key - * @returns {string[]} - */ - _extractInfoFromBasicDomains(filter, filterQueryElements, key) { - const results = []; - const yearIds = []; - const otherOptionIds = []; - filterQueryElements.forEach(({ optionId }) => { - if (YEAR_OPTIONS[optionId]) { - yearIds.push(optionId); - } else { - otherOptionIds.push(optionId); - } - }); - - const sortOptionIds = (a, b) => rankPeriod(a) - rankPeriod(b); - yearIds.sort(sortOptionIds); - otherOptionIds.sort(sortOptionIds); - - // the following case corresponds to years selected only - if (otherOptionIds.length === 0) { - yearIds.forEach(yearId => { - const d = filter.basicDomains[yearId]; - results.push(d[key]); - }); - } else { - otherOptionIds.forEach(optionId => { - yearIds.forEach(yearId => { - const d = filter.basicDomains[`${yearId}__${optionId}`]; - results.push(d[key]); - }); - }); - } - return results; - } - - /** - * Construct a timeRange object from the given fieldName, rangeId, comparisonRangeId - * parameters. - * @private - * @param {string} fieldName - * @param {string} rangeId - * @param {string} comparisonRangeId - * @returns {Object} - */ - _extractTimeRange({ fieldName, rangeId, comparisonRangeId }) { - const field = this.fields[fieldName]; - const timeRange = { - fieldName, - fieldDescription: field.string || fieldName, - rangeId, - range: Domain.prototype.constructDomain(fieldName, rangeId, field.type), - rangeDescription: TIME_RANGE_OPTIONS[rangeId].description.toString(), - }; - if (comparisonRangeId) { - timeRange.comparisonRangeId = comparisonRangeId; - timeRange.comparisonRange = Domain.prototype.constructDomain( - fieldName, rangeId, field.type, comparisonRangeId - ); - const { description } = COMPARISON_TIME_RANGE_OPTIONS[comparisonRangeId]; - timeRange.comparisonRangeDescription = description.toString(); - } - return timeRange; - } - - /** * Returns an object irFilter serving to create an ir_filte in db * starting from a filter of type 'favorite'. * @private @@ -1003,13 +1016,8 @@ odoo.define('web.ControlPanelModel', function (require) { if ('groupBys' in favorite) { context.group_by = favorite.groupBys; } - if ('timeRanges' in favorite) { - const { fieldName, rangeId, comparisonRangeId } = favorite.timeRanges; - context.time_ranges = { - field: fieldName, - range: rangeId, - comparisonRange: comparisonRangeId, - }; + if ('comparison' in favorite) { + context.comparison = favorite.comparison; } if (Object.keys(context).length) { irFilter.context = context; @@ -1078,56 +1086,21 @@ odoo.define('web.ControlPanelModel', function (require) { } /** - * Construct an object containing domains based on this.referenceMoment and - * the field associated with the provided date filter. - * @private - * @param {Object} filter - * @returns {Object} - */ - _getDateFilterBasicDomains({ fieldName, fieldType }) { - const _constructBasicDomain = (y, o) => { - const setParam = Object.assign({}, y.setParam, o ? o.setParam : {}); - const granularity = o ? o.granularity : y.granularity; - const date = this.referenceMoment.clone().set(setParam); - let leftBound = date.clone().startOf(granularity).locale('en'); - let rightBound = date.clone().endOf(granularity).locale('en'); - if (fieldType === 'date') { - leftBound = leftBound.format('YYYY-MM-DD'); - rightBound = rightBound.format('YYYY-MM-DD'); - } else { - leftBound = leftBound.utc().format('YYYY-MM-DD HH:mm:ss'); - rightBound = rightBound.utc().format('YYYY-MM-DD HH:mm:ss'); - } - const domain = Domain.prototype.arrayToString([ - '&', - [fieldName, '>=', leftBound], - [fieldName, '<=', rightBound] - ]); - const description = o ? o.description + " " + y.description : y.description; - return { domain, description }; - }; - - const domains = {}; - this.optionGenerators.filter(y => y.groupNumber === 2).forEach(y => { - domains[y.id] = _constructBasicDomain(y); - this.optionGenerators.filter(y => y.groupNumber === 1).forEach(o => { - domains[y.id + '__' + o.id] = _constructBasicDomain(y, o); - }); - }); - return domains; - } - - /** - * Compute the string representation of the current domain associated to a date filter - * starting from its corresponding query elements. + * 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 {Objec[]} filterQueryElements + * @param {Object[]} filterQueryElements + * @param {'domain'|'description'} [key='domain'] * @returns {string} */ - _getDateFilterDomain(filter, filterQueryElements) { - const domains = this._extractInfoFromBasicDomains(filter, filterQueryElements, 'domain'); - return pyUtils.assembleDomains(domains, 'OR'); + _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]; } /** @@ -1205,6 +1178,10 @@ odoo.define('web.ControlPanelModel', function (require) { */ _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); @@ -1307,7 +1284,7 @@ odoo.define('web.ControlPanelModel', function (require) { /** * Reconstruct the (active) groups from the query elements. * @private - * @returns {Objec[]} + * @returns {Object[]} */ _getGroups() { const groups = this.state.query.reduce( @@ -1356,37 +1333,20 @@ odoo.define('web.ControlPanelModel', function (require) { } /** - * Returns the last timeRanges object found in the query. - * TimeRanges objects can be associated with filters of type 'favorite' - * or 'timeRange'. + * Starting from the id of a date filter, returns the array of option ids currently selected + * for the corresponding filter. * @private - * @param {boolean} [evaluation=false] - * @returns {(Object|undefined)} + * @param {string} dateFilterId + * @returns {string[]} */ - _getTimeRanges(evaluation = false) { - let timeRanges; - for (const queryElem of this.state.query.slice().reverse()) { - const filter = this.state.filters[queryElem.filterId]; - if (filter.type === 'timeRange') { - timeRanges = this._extractTimeRange(queryElem); - break; - } else if (filter.type === 'favorite' && filter.timeRanges) { - // we want to make sure that last is not observed! (it is change below in case of evaluation) - timeRanges = this._extractTimeRange(filter.timeRanges); - break; - } - } - if (timeRanges) { - if (evaluation) { - timeRanges.range = Domain.prototype.stringToArray(timeRanges.range); - if (timeRanges.comparisonRangeId) { - timeRanges.comparisonRange = Domain.prototype.stringToArray( - timeRanges.comparisonRange - ); - } + _getSelectedOptionIds(dateFilterId) { + const selectedOptionIds = []; + for (const queryElem of this.state.query) { + if (queryElem.filterId === dateFilterId) { + selectedOptionIds.push(queryElem.optionId); } - return timeRanges; } + return selectedOptionIds; } /** @@ -1407,15 +1367,10 @@ odoo.define('web.ControlPanelModel', function (require) { groupBys = context.group_by; delete context.group_by; } - let timeRanges; - if (context.time_ranges) { - const { field, range, comparisonRange } = context.time_ranges; - timeRanges = this._extractTimeRange({ - fieldName: field, - rangeId: range, - comparisonRangeId: comparisonRange, - }); - delete context.time_ranges; + let comparison; + if (context.comparison) { + comparison = context.comparison; + delete context.comparison; } let sort; try { @@ -1460,8 +1415,8 @@ odoo.define('web.ControlPanelModel', function (require) { if (irFilter.is_default) { favorite.isDefault = irFilter.is_default; } - if (timeRanges) { - favorite.timeRanges = timeRanges; + if (comparison) { + favorite.comparison = comparison; } return favorite; } @@ -1470,6 +1425,7 @@ odoo.define('web.ControlPanelModel', function (require) { * 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) { @@ -1477,7 +1433,7 @@ odoo.define('web.ControlPanelModel', function (require) { let res = []; switch (type) { case 'filter': - case 'groupBy': + case 'groupBy': { for (const activity of activities) { const { filterId } = activity; let a = res.find(({ filter }) => filter.id === filterId); @@ -1491,9 +1447,10 @@ odoo.define('web.ControlPanelModel', function (require) { a.filterQueryElements.push(activity); } break; + } case 'favorite': case 'field': - case 'timeRange': + case 'comparison': { // all activities in the group have same filterId const { filterId } = group.activities[0]; const filter = this.state.filters[filterId]; @@ -1502,6 +1459,7 @@ odoo.define('web.ControlPanelModel', function (require) { filterQueryElements: group.activities }); break; + } } if (type === 'groupBy') { res.forEach(activity => { @@ -1608,7 +1566,7 @@ odoo.define('web.ControlPanelModel', function (require) { userId, }); if (timeRanges) { - preFilter.timeRanges = timeRanges; + preFilter.comparison = timeRanges; } const irFilter = this._favoriteToIrFilter(preFilter); const serverSideId = await this.env.dataManager.create_filter(irFilter); @@ -1648,20 +1606,13 @@ odoo.define('web.ControlPanelModel', function (require) { } else { let facetDescription; for (const { filter, filterQueryElements } of activities) { - if (type === 'timeRange') { - const tr = this._extractTimeRange(filterQueryElements[0]); - facetDescription = `${tr.fieldDescription}: ${tr.rangeDescription}`; - if (tr.comparisonRangeDescription) { - facetDescription += ` / ${tr.comparisonRangeDescription}`; - } - } else { // filter and favorite - facetDescription = filter.description; - if (filter.hasOptions) { - const descriptions = this._extractInfoFromBasicDomains( - filter, filterQueryElements, 'description' - ); - facetDescription += `: ${descriptions.join(" / ")}`; - } + // filter, favorite and comparison + facetDescription = filter.description; + if (filter.isDateFilter) { + const description = this._getDateFilterDomain( + filter, filterQueryElements, 'description' + ); + facetDescription += `: ${description}`; } facetDescriptions.push(facetDescription); } @@ -1670,6 +1621,35 @@ odoo.define('web.ControlPanelModel', function (require) { } /** + * 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 @@ -1713,26 +1693,9 @@ odoo.define('web.ControlPanelModel', function (require) { this.dynamicFilters = config.dynamicFilters || []; this.referenceMoment = moment(); - const setDescriptions = options => { - return Object.values(options).map(o => { - const oClone = JSON.parse(JSON.stringify(o)); - const description = o.description ? - o.description.toString() : - this.referenceMoment.clone().add(o.addParam).format(o.format); - return Object.assign(oClone, { description }); - }); - }; - const process = (options) => { - return options.map(o => { - const date = this.referenceMoment.clone().set(o.setParam).add(o.addParam); - delete o.addParam; - o.setParam[o.granularity] = date[o.granularity](); - o.defaultYear = date.year(); - return o; - }); - }; - this.optionGenerators = process(setDescriptions(OPTION_GENERATORS)); - this.intervalOptions = setDescriptions(INTERVAL_OPTIONS); + this.optionGenerators = getPeriodOptions(this.referenceMoment); + this.intervalOptions = getIntervalOptions(); + this.comparisonOptions = getComparisonOptions(); } } diff --git a/addons/web/static/src/js/control_panel/search_utils.js b/addons/web/static/src/js/control_panel/search_utils.js index 3a605faa2b8..cd4ae869651 100644 --- a/addons/web/static/src/js/control_panel/search_utils.js +++ b/addons/web/static/src/js/control_panel/search_utils.js @@ -1,7 +1,13 @@ odoo.define('web.searchUtils', function (require) { "use strict"; - const { _lt } = require('web.core'); + const { _lt, _t } = require('web.core'); + const Domain = require('web.Domain'); + const pyUtils = require('web.py_utils'); + + //------------------------------------------------------------------------- + // Constants + //------------------------------------------------------------------------- // Filter menu parameters const FIELD_OPERATORS = { @@ -10,7 +16,7 @@ odoo.define('web.searchUtils', function (require) { { symbol: "!=", description: _lt("is false"), value: true }, ], char: [ - { symbol: "ilike", description:_lt("contains") }, + { symbol: "ilike", description: _lt("contains") }, { symbol: "not ilike", description: _lt("doesn't contain") }, { symbol: "=", description: _lt("is equal to") }, { symbol: "!=", description: _lt("is not equal to") }, @@ -40,7 +46,7 @@ odoo.define('web.searchUtils', function (require) { { symbol: "=", description: _lt("is not set"), value: false }, ], id: [ - { symbol: "=", description:_lt("is")}, + { symbol: "=", description: _lt("is") }, ], number: [ { symbol: "=", description: _lt("is equal to") }, @@ -53,7 +59,7 @@ odoo.define('web.searchUtils', function (require) { { symbol: "=", description: _lt("is not set"), value: false }, ], selection: [ - { symbol: "=", description:_lt("is")}, + { symbol: "=", description: _lt("is") }, { symbol: "!=", description: _lt("is not") }, { symbol: "!=", description: _lt("is set"), value: false }, { symbol: "=", description: _lt("is not set"), value: false }, @@ -76,30 +82,70 @@ odoo.define('web.searchUtils', function (require) { selection: 'selection', }; const DEFAULT_PERIOD = 'this_month'; + const QUARTERS = { + 1: { description: _lt("Q1"), coveredMonths: [0, 1, 2] }, + 2: { description: _lt("Q2"), coveredMonths: [3, 4, 5] }, + 3: { description: _lt("Q3"), coveredMonths: [6, 7, 8] }, + 4: { description: _lt("Q4"), coveredMonths: [9, 10, 11] }, + }; const MONTH_OPTIONS = { - this_month: { id: 'this_month', groupNumber: 1, format: 'MMMM', addParam: {}, setParam: {}, granularity: 'month' }, - last_month: { id: 'last_month', groupNumber: 1, format: 'MMMM', addParam: { months: -1 }, setParam: {}, granularity: 'month' }, - antepenultimate_month: { id: 'antepenultimate_month', groupNumber: 1, format: 'MMMM', addParam: { months: -2 }, setParam: {}, granularity: 'month' }, + this_month: { + id: 'this_month', groupNumber: 1, format: 'MMMM', + addParam: {}, granularity: 'month', + }, + last_month: { + id: 'last_month', groupNumber: 1, format: 'MMMM', + addParam: { months: -1 }, granularity: 'month', + }, + antepenultimate_month: { + id: 'antepenultimate_month', groupNumber: 1, format: 'MMMM', + addParam: { months: -2 }, granularity: 'month', + }, }; const QUARTER_OPTIONS = { - fourth_quarter: { id: 'fourth_quarter', groupNumber: 1, description: _lt("Q4"), addParam: {}, setParam: { quarter: 4 }, granularity: 'quarter' }, - third_quarter: { id: 'third_quarter', groupNumber: 1, description: _lt("Q3"), addParam: {}, setParam: { quarter: 3 }, granularity: 'quarter' }, - second_quarter: { id: 'second_quarter', groupNumber: 1, description: _lt("Q2"), addParam: {}, setParam: { quarter: 2 }, granularity: 'quarter' }, - first_quarter: { id: 'first_quarter', groupNumber: 1, description: _lt("Q1"), addParam: {}, setParam: { quarter: 1 }, granularity: 'quarter' }, + fourth_quarter: { + id: 'fourth_quarter', groupNumber: 1, description: QUARTERS[4].description, + setParam: { quarter: 4 }, granularity: 'quarter', + }, + third_quarter: { + id: 'third_quarter', groupNumber: 1, description: QUARTERS[3].description, + setParam: { quarter: 3 }, granularity: 'quarter', + }, + second_quarter: { + id: 'second_quarter', groupNumber: 1, description: QUARTERS[2].description, + setParam: { quarter: 2 }, granularity: 'quarter', + }, + first_quarter: { + id: 'first_quarter', groupNumber: 1, description: QUARTERS[1].description, + setParam: { quarter: 1 }, granularity: 'quarter', + }, }; const YEAR_OPTIONS = { - this_year: { id: 'this_year', groupNumber: 2, format: 'YYYY', addParam: {}, setParam: {}, granularity: 'year' }, - last_year: { id: 'last_year', groupNumber: 2, format: 'YYYY', addParam: { years: -1 }, setParam: {}, granularity: 'year' }, - antepenultimate_year: { id: 'antepenultimate_year', groupNumber: 2, format: 'YYYY', addParam: { years: -2 }, setParam: {}, granularity: 'year' }, + this_year: { + id: 'this_year', groupNumber: 2, format: 'YYYY', + addParam: {}, granularity: 'year', + }, + last_year: { + id: 'last_year', groupNumber: 2, format: 'YYYY', + addParam: { years: -1 }, granularity: 'year', + }, + antepenultimate_year: { + id: 'antepenultimate_year', groupNumber: 2, format: 'YYYY', + addParam: { years: -2 }, granularity: 'year', + }, }; - const OPTION_GENERATORS = Object.assign({}, MONTH_OPTIONS, QUARTER_OPTIONS, YEAR_OPTIONS); - - function rankPeriod(oId) { - return Object.keys(OPTION_GENERATORS).indexOf(oId); - } + const PERIOD_OPTIONS = Object.assign({}, MONTH_OPTIONS, QUARTER_OPTIONS, YEAR_OPTIONS); // GroupBy menu parameters - const GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime', 'integer']; + const GROUPABLE_TYPES = [ + 'boolean', + 'char', + 'date', + 'datetime', + 'integer', + 'many2one', + 'selection', + ]; const DEFAULT_INTERVAL = 'month'; const INTERVAL_OPTIONS = { year: { description: _lt("Year"), id: 'year', groupNumber: 1 }, @@ -108,42 +154,373 @@ odoo.define('web.searchUtils', function (require) { week: { description: _lt("Week"), id: 'week', groupNumber: 1 }, day: { description: _lt("Day"), id: 'day', groupNumber: 1 } }; - function rankInterval(oId) { - return Object.keys(INTERVAL_OPTIONS).indexOf(oId); - } - // TimeRange menu parameters - const TIME_RANGE_OPTIONS = { - last_7_days: { description: _lt("Last 7 Days"), id: 'last_7_days', groupNumber: 1 }, - last_30_days: { description: _lt("Last 30 Days"), id: 'last_30_days', groupNumber: 1 }, - last_365_days: { description: _lt("Last 365 Days"), id: 'last_365_days', groupNumber: 1 }, - last_5_years: { description: _lt("Last 5 Years"), id: 'last_5_years', groupNumber: 1 }, - today: { description: _lt("Today"), id: 'today', groupNumber: 2 }, - this_week: { description: _lt("This Week"), id: 'this_week', groupNumber: 2 }, - this_month: { description: _lt("This Month"), id: 'this_month', groupNumber: 2 }, - this_quarter: { description: _lt("This Quarter"), id: 'this_quarter', groupNumber: 2 }, - this_year: { description: _lt("This Year"), id: 'this_year', groupNumber: 2 }, - yesterday: { description: _lt("Yesterday"), id: 'yesterday', groupNumber: 3 }, - last_week: { description: _lt("Last Week"), id: 'last_week', groupNumber: 3 }, - last_month: { description: _lt("Last Month"), id: 'last_month', groupNumber: 3 }, - last_quarter: { description: _lt("Last Quarter"), id: 'last_quarter', groupNumber: 3 }, - last_year: { description: _lt("Last Year"), id: 'last_year', groupNumber: 3 }, + // Comparison menu parameters + const COMPARISON_OPTIONS = { + previous_period: { + description: _lt("Previous Period"), id: 'previous_period', + }, + previous_year: { + description: _lt("Previous Year"), id: 'previous_year', addParam: { years: -1 }, + }, }; - const COMPARISON_TIME_RANGE_OPTIONS = { - previous_period: { description: _lt("Previous Period"), id: 'previous_period' }, - previous_year: { description: _lt("Previous Year"), id: 'previous_year' }, + const PER_YEAR = { + year: 1, + quarter: 4, + month: 12, }; - // Search bar const FACET_ICONS = { filter: 'fa fa-filter', groupBy: 'fa fa-bars', favorite: 'fa fa-star', - timeRange: 'fa fa-calendar', + comparison: 'fa fa-adjust', }; + //------------------------------------------------------------------------- + // Functions + //------------------------------------------------------------------------- + + /** + * Constructs the string representation of a domain and its description. The + * domain is of the form: + * ['|',..., '|', d_1,..., d_n] + * where d_i is a time range of the form + * ['&', [fieldName, >=, leftBound_i], [fieldName, <=, rightBound_i]] + * where leftBound_i and rightBound_i are date or datetime computed accordingly + * to the given options and reference moment. + * (@see constructDateRange). + * @param {moment} referenceMoment + * @param {string} fieldName + * @param {string} fieldType + * @param {string[]} selectedOptionIds + * @param {string} [comparisonOptionId] + * @returns {{ domain: string, description: string }} + */ + function constructDateDomain( + referenceMoment, + fieldName, + fieldType, + selectedOptionIds, + comparisonOptionId + ) { + let addParam; + let selectedOptions; + if (comparisonOptionId) { + [addParam, selectedOptions] = getComparisonParams( + referenceMoment, + selectedOptionIds, + comparisonOptionId); + } else { + selectedOptions = getSelectedOptions(referenceMoment, selectedOptionIds); + } + + const yearOptions = selectedOptions.year; + const otherOptions = [ + ...(selectedOptions.quarter || []), + ...(selectedOptions.month || []) + ]; + + sortPeriodOptions(yearOptions); + sortPeriodOptions(otherOptions); + + const ranges = []; + for (const yearOption of yearOptions) { + const constructRangeParams = { + referenceMoment, + fieldName, + fieldType, + addParam, + }; + if (otherOptions.length) { + for (const option of otherOptions) { + const setParam = Object.assign({}, + yearOption.setParam, + option ? option.setParam : {} + ); + const { granularity } = option; + const range = constructDateRange(Object.assign( + { granularity, setParam }, + constructRangeParams + )); + ranges.push(range); + } + } else { + const { granularity, setParam } = yearOption; + const range = constructDateRange(Object.assign( + { granularity, setParam }, + constructRangeParams + )); + ranges.push(range); + } + } + + const domain = pyUtils.assembleDomains(ranges.map(range => range.domain), 'OR'); + const description = ranges.map(range => range.description).join("/"); + + return { domain, description }; + } + + /** + * Constructs the string representation of a domain and its description. The + * domain is a time range of the form: + * ['&', [fieldName, >=, leftBound],[fieldName, <=, rightBound]] + * where leftBound and rightBound are some date or datetime determined by setParam, + * addParam, granularity and the reference moment. + * @param {Object} params + * @param {moment} params.referenceMoment + * @param {string} params.fieldName + * @param {string} params.fieldType + * @param {string} params.granularity + * @param {Object} params.setParam + * @param {Object} [params.addParam] + * @returns {{ domain: string, description: string }} + */ + function constructDateRange({ + referenceMoment, + fieldName, + fieldType, + granularity, + setParam, + addParam, + }) { + const date = referenceMoment.clone().set(setParam).add(addParam || {}); + + // compute domain + let leftBound = date.clone().startOf(granularity); + let rightBound = date.clone().endOf(granularity); + if (fieldType === 'date') { + leftBound = leftBound.format('YYYY-MM-DD'); + rightBound = rightBound.format('YYYY-MM-DD'); + } else { + leftBound = leftBound.utc().format('YYYY-MM-DD HH:mm:ss'); + rightBound = rightBound.utc().format('YYYY-MM-DD HH:mm:ss'); + } + const domain = Domain.prototype.arrayToString([ + '&', + [fieldName, '>=', leftBound], + [fieldName, '<=', rightBound] + ]); + + // compute description + const descriptions = [date.format("YYYY")]; + const method = _t.database.parameters.direction === "rtl" ? "push" : "unshift"; + if (granularity === "month") { + descriptions[method](date.format("MMMM")); + } else if (granularity === "quarter") { + descriptions[method](QUARTERS[date.quarter()].description); + } + const description = descriptions.join(" "); + + return { domain, description, }; + } + + /** + * Returns a version of the options in COMPARISON_OPTIONS with translated descriptions. + * @see getOptionsWithDescriptions + */ + function getComparisonOptions() { + return getOptionsWithDescriptions(COMPARISON_OPTIONS); + } + + /** + * Returns the params addParam and selectedOptions necessary for the computation + * of a comparison domain. + * @param {moment} referenceMoment + * @param {string{}} selectedOptionIds + * @param {string} comparisonOptionId + * @returns {Object[]} + */ + function getComparisonParams(referenceMoment, selectedOptionIds, comparisonOptionId) { + const comparisonOption = COMPARISON_OPTIONS[comparisonOptionId]; + const selectedOptions = getSelectedOptions(referenceMoment, selectedOptionIds); + let addParam = comparisonOption.addParam; + if (addParam) { + return [addParam, selectedOptions]; + } + addParam = {}; + + let globalGranularity = 'year'; + if (selectedOptions.month) { + globalGranularity = 'month'; + } else if (selectedOptions.quarter) { + globalGranularity = 'quarter'; + } + const granularityFactor = PER_YEAR[globalGranularity]; + const years = selectedOptions.year.map(o => o.setParam.year); + const yearMin = Math.min(...years); + const yearMax = Math.max(...years); + + let optionMin = 0; + let optionMax = 0; + if (selectedOptions.quarter) { + const quarters = selectedOptions.quarter.map(o => o.setParam.quarter); + if (globalGranularity === 'month') { + delete selectedOptions.quarter; + for (const quarter of quarters) { + for (const month of QUARTERS[quarter].coveredMonths) { + const monthOption = selectedOptions.month.find( + o => o.setParam.month === month + ); + if (!monthOption) { + selectedOptions.month.push({ + setParam: { month, }, granularity: 'month', + }); + } + } + } + } else { + optionMin = Math.min(...quarters); + optionMax = Math.max(...quarters); + } + } + if (selectedOptions.month) { + const months = selectedOptions.month.map(o => o.setParam.month); + optionMin = Math.min(...months); + optionMax = Math.max(...months); + } + + addParam[globalGranularity] = -1 + + granularityFactor * (yearMin - yearMax) + + optionMin - optionMax; + + return [addParam, selectedOptions]; + } + + /** + * Returns a version of the options in INTERVAL_OPTIONS with translated descriptions. + * @see getOptionsWithDescriptions + */ + function getIntervalOptions() { + return getOptionsWithDescriptions(INTERVAL_OPTIONS); + } + + /** + * Returns a version of the options in PERIOD_OPTIONS with translated descriptions + * and a key defautlYearId used in the control panel model when toggling a period option. + * @param {moment} referenceMoment + * @returns {Object[]} + */ + function getPeriodOptions(referenceMoment) { + const options = []; + for (const option of Object.values(PERIOD_OPTIONS)) { + const { id, groupNumber, description, } = option; + const res = { id, groupNumber, }; + const date = referenceMoment.clone().set(option.setParam).add(option.addParam); + if (description) { + res.description = description.toString(); + } else { + res.description = date.format(option.format.toString()); + } + res.setParam = getSetParam(option, referenceMoment); + res.defaultYear = date.year(); + options.push(res); + } + for (const option of options) { + const yearOption = options.find( + o => o.setParam && o.setParam.year === option.defaultYear + ); + option.defaultYearId = yearOption.id; + delete option.defaultYear; + delete option.setParam; + } + return options; + } + + /** + * Returns a version of the options in OPTIONS with translated descriptions (if any). + * @param {Object{}} OPTIONS + * @returns {Object[]} + */ + function getOptionsWithDescriptions(OPTIONS) { + const options = []; + for (const option of Object.values(OPTIONS)) { + const { id, groupNumber, description, } = option; + const res = { id, }; + if (description) { + res.description = description.toString(); + } + if (groupNumber) { + res.groupNumber = groupNumber; + } + options.push(res); + } + return options; + } + + /** + * Returns a version of the period options whose ids are in selectedOptionIds + * partitioned by granularity. + * @param {moment} referenceMoment + * @param {string[]} selectedOptionIds + * @param {Object} + */ + function getSelectedOptions(referenceMoment, selectedOptionIds) { + const selectedOptions = { year: [] }; + for (const optionId of selectedOptionIds) { + const option = PERIOD_OPTIONS[optionId]; + const setParam = getSetParam(option, referenceMoment); + const granularity = option.granularity; + if (!selectedOptions[granularity]) { + selectedOptions[granularity] = []; + } + selectedOptions[granularity].push({ granularity, setParam }); + } + return selectedOptions; + } + + /** + * Returns the setParam object associated with the given periodOption and + * referenceMoment. + * @param {Object} periodOption + * @param {moment} referenceMoment + * @returns {Object} + */ + function getSetParam(periodOption, referenceMoment) { + if (periodOption.setParam) { + return periodOption.setParam; + } + const date = referenceMoment.clone().add(periodOption.addParam); + const setParam = {}; + setParam[periodOption.granularity] = date[periodOption.granularity](); + return setParam; + } + + /** + * @param {string} intervalOptionId + * @returns {number} index + */ + function rankInterval(intervalOptionId) { + return Object.keys(INTERVAL_OPTIONS).indexOf(intervalOptionId); + } + + /** + * Sorts in place an array of 'period' options. + * @param {Object[]} options supposed to be of the form: + * { granularity, setParam, } + */ + function sortPeriodOptions(options) { + options.sort((o1, o2) => { + const granularity1 = o1.granularity; + const granularity2 = o2.granularity; + if (granularity1 === granularity2) { + return o1.setParam[granularity1] - o2.setParam[granularity1]; + } + return granularity1 < granularity2 ? -1 : 1; + }); + } + + /** + * Checks if a year id is among the given array of period option ids. + * @param {string[]} selectedOptionIds + * @returns {boolean} + */ + function yearSelected(selectedOptionIds) { + return selectedOptionIds.some(optionId => !!YEAR_OPTIONS[optionId]); + } + return { - COMPARISON_TIME_RANGE_OPTIONS, + COMPARISON_OPTIONS, DEFAULT_INTERVAL, DEFAULT_PERIOD, FACET_ICONS, @@ -151,11 +528,13 @@ odoo.define('web.searchUtils', function (require) { FIELD_TYPES, GROUPABLE_TYPES, INTERVAL_OPTIONS, - OPTION_GENERATORS, - TIME_RANGE_OPTIONS, - YEAR_OPTIONS, + PERIOD_OPTIONS, + constructDateDomain, + getComparisonOptions, + getIntervalOptions, + getPeriodOptions, rankInterval, - rankPeriod, + yearSelected, }; }); diff --git a/addons/web/static/src/js/control_panel/time_range_menu.js b/addons/web/static/src/js/control_panel/time_range_menu.js deleted file mode 100644 index b18b376d40b..00000000000 --- a/addons/web/static/src/js/control_panel/time_range_menu.js +++ /dev/null @@ -1,109 +0,0 @@ -odoo.define('web.TimeRangeMenu', function (require) { - "use strict"; - - const { COMPARISON_TIME_RANGE_OPTIONS, DEFAULT_PERIOD, TIME_RANGE_OPTIONS } = require('web.searchUtils'); - const DropdownMenu = require('web.DropdownMenu'); - const { useModel } = require('web.model'); - - // Used to provide unique ids to its template elements. - let timeRangeMenuId = 0; - - /** - * 'Time ranges' menu - * - * Component used to create a time range from a field, a given range and optionally - * another field to make a comparison. - * - * The component template overrides the dropdownmenu to keep the basic behaviours - * (opening/closing, layout). The template itself iis a set of labels/inputs - * used to select the field and range. There is also a checkbox used to determine - * whether to render the comparison range field selection (input). - * @extends DropdownMenu - */ - class TimeRangeMenu extends DropdownMenu { - constructor() { - super(...arguments); - this.model = useModel('controlPanelModel'); - - this.domId = timeRangeMenuId++; - this.fields = Object.values(this.props.fields) - .filter(field => this._validateField(field)); - this.periodOptions = TIME_RANGE_OPTIONS; - this.comparisonTimeRangeOptions = COMPARISON_TIME_RANGE_OPTIONS; - this.periodGroups = Object.values(this.periodOptions).reduce((acc, o) => { - if (!acc.includes(o.groupNumber)) { - acc.push(o.groupNumber); - } - return acc; - }, []); - const activeTimeRange = this.model.getFiltersOfType('timeRange').find( - timeRange => timeRange.isActive - ); - Object.assign(this.state, { - fieldName: this.fields[0] && this.fields[0].name, - rangeId: DEFAULT_PERIOD, - }, activeTimeRange); - } - - //--------------------------------------------------------------------- - // Getters - //--------------------------------------------------------------------- - - /** - * @override - */ - get title() { - return this.env._t("Time Ranges"); - } - - //--------------------------------------------------------------------- - // Private - //--------------------------------------------------------------------- - - /** - * @private - * @param {Object} field - * @returns {boolean} - */ - _validateField(field) { - return field.sortable && - ['date', 'datetime'].includes(field.type); - } - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - /** - * @private - */ - _onApply() { - this.model.dispatch('activateTimeRange', - this.state.fieldName, - this.state.rangeId, - this.state.comparisonRangeId - ); - } - - /** - * @private - */ - _onCheckboxClick() { - if (!this.state.comparisonRangeId) { - this.state.comparisonRangeId = 'previous_period'; // default - } else { - delete this.state.comparisonRangeId; - } - } - } - - TimeRangeMenu.defaultProps = Object.assign({}, DropdownMenu.defaultProps, { - icon: 'fa fa-calendar', - }); - TimeRangeMenu.props = Object.assign({}, DropdownMenu.props, { - fields: Object, - }); - TimeRangeMenu.template = 'web.TimeRangeMenu'; - - return TimeRangeMenu; -}); diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js index 07d785b9292..ad0cfba9ae4 100644 --- a/addons/web/static/src/js/core/domain.js +++ b/addons/web/static/src/js/core/domain.js @@ -244,132 +244,6 @@ var Domain = collections.Tree.extend({ .replace(/false/g, "False") .replace(/true/g, "True"); }, - /* - * @param {string} fieldName - * @param {string} period - * @param {string} type ('date' or 'datetime') - * @param {string} comparisonPeriod - * @returns {string} a domain in string form - */ - constructDomain: function (fieldName, period, type, comparisonPeriod) { - var leftBoundaryParams, rightBoundaryParams; - var offsetPeriodParams; - function makeInterval () { - switch (comparisonPeriod) { - case 'previous_period': - _.each(offsetPeriodParams, function (value, key) { - if (!leftBoundaryParams[key] ||_.isNumber(leftBoundaryParams[key])) { - leftBoundaryParams[key] = value + (leftBoundaryParams[key] || 0); - } else { - leftBoundaryParams[key] = value + ' + ' + leftBoundaryParams[key]; - } - if (!rightBoundaryParams[key] || _.isNumber(rightBoundaryParams[key])) { - rightBoundaryParams[key] = value + (rightBoundaryParams[key] || 0); - } else { - rightBoundaryParams[key] = value + ' + ' + rightBoundaryParams[key]; - } - }); - break; - case 'previous_year': - leftBoundaryParams.years = leftBoundaryParams.years ? leftBoundaryParams.years - 1 : -1; - rightBoundaryParams.years = rightBoundaryParams.years ? rightBoundaryParams.years - 1 : -1; - break; - } - - var stringifyParams = function (value, key) { - return key + '=' + value; - }; - var leftBoundaryStringifyParams = _.map(leftBoundaryParams, stringifyParams).join(', '); - var rightBoundaryStringifyParams = _.map(rightBoundaryParams, stringifyParams).join(', '); - - if (type === 'date') { - return "['&'," + - "('" + fieldName + "', '>=', (context_today() + relativedelta(" + leftBoundaryStringifyParams + ")).strftime('%Y-%m-%d'))," + - "('" + fieldName + "', '<', (context_today() + relativedelta(" + rightBoundaryStringifyParams + ")).strftime('%Y-%m-%d'))"+ - "]"; - } - else { - return "['&'," + - "('" + fieldName + "', '>=', " + - "(datetime.datetime.combine(context_today() + relativedelta(" + leftBoundaryStringifyParams + "), datetime.time(0,0,0)).to_utc()).strftime('%Y-%m-%d %H:%M:%S'))," + - "('" + fieldName + "', '<', " + - "(datetime.datetime.combine(context_today() + relativedelta(" + rightBoundaryStringifyParams + "), datetime.time(0,0,0)).to_utc()).strftime('%Y-%m-%d %H:%M:%S'))"+ - "]"; - } - } - switch (period) { - case 'today': - leftBoundaryParams = {}; - rightBoundaryParams = {days: 1}; - offsetPeriodParams = {days: -1}; - return makeInterval(); - case 'this_week': - leftBoundaryParams = {weeks: -1, days: 1, weekday: 0}; - rightBoundaryParams = {days: 1, weekday: 0}; - offsetPeriodParams = {weeks: -1}; - return makeInterval(); - case 'this_month': - leftBoundaryParams = {day: 1}; - rightBoundaryParams = {day: 1, months: 1}; - offsetPeriodParams = {months: -1}; - return makeInterval(); - case 'this_quarter': - leftBoundaryParams = {months: '- (context_today().month - 1) % 3', day: 1}; - rightBoundaryParams = {months: '3 - (context_today().month - 1) % 3', day: 1}; - offsetPeriodParams = {months: -3}; - return makeInterval(); - case 'this_year': - leftBoundaryParams = {month: 1, day: 1}; - rightBoundaryParams = {month: 1, day: 1, years: 1}; - offsetPeriodParams = {years: -1}; - return makeInterval(); - case 'yesterday': - leftBoundaryParams = {days: -1}; - rightBoundaryParams = {}; - offsetPeriodParams = {days: -1}; - return makeInterval(); - case 'last_week': - leftBoundaryParams = {weeks: -2, days: 1, weekday: 0}; - rightBoundaryParams = {weeks: -1, days: 1, weekday: 0}; - offsetPeriodParams = {weeks: -1}; - return makeInterval(); - case 'last_month': - leftBoundaryParams = {months: -1, day: 1}; - rightBoundaryParams = {day: 1}; - offsetPeriodParams = {months: -1}; - return makeInterval(); - case 'last_quarter': - leftBoundaryParams = {months: '- 3 - (context_today().month - 1) % 3', day: 1}; - rightBoundaryParams = {months: '- (context_today().month - 1) % 3', day: 1}; - offsetPeriodParams = {months: -3}; - return makeInterval(); - case 'last_year': - leftBoundaryParams = {month: 1, day: 1, years: -1}; - rightBoundaryParams = {month: 1, day: 1}; - offsetPeriodParams = {years: -1}; - return makeInterval(); - case 'last_7_days': - leftBoundaryParams = {days: -7}; - rightBoundaryParams = {}; - offsetPeriodParams = {days: -7}; - return makeInterval(); - case 'last_30_days': - leftBoundaryParams = {days: -30}; - rightBoundaryParams = {}; - offsetPeriodParams = {days: -30}; - return makeInterval(); - case 'last_365_days': - leftBoundaryParams = {days: -365}; - rightBoundaryParams = {}; - offsetPeriodParams = {days: -365}; - return makeInterval(); - case 'last_5_years': - leftBoundaryParams = {years: -5}; - rightBoundaryParams = {}; - offsetPeriodParams = {years: -5}; - return makeInterval(); - } - }, /** * Converts a string representation of the Python prefix-array * representation of a domain to a JS prefix-array representation of this diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js index 6ee4d06651e..ccaa008f3f7 100644 --- a/addons/web/static/src/js/views/graph/graph_model.js +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -150,12 +150,11 @@ return AbstractModel.extend({ const { range, rangeDescription, comparisonRange, comparisonRangeDescription, fieldName } = this.chart.timeRanges; if (range) { - this.chart.domains = [this.chart.domain.concat(range)]; - this.chart.origins = [rangeDescription]; - if (comparisonRange) { - this.chart.domains.push(this.chart.domain.concat(comparisonRange)); - this.chart.origins.push(comparisonRangeDescription); - } + this.chart.domains = [ + this.chart.domain.concat(range), + this.chart.domain.concat(comparisonRange), + ]; + this.chart.origins = [rangeDescription, comparisonRangeDescription]; const groupBys = this.chart.processedGroupBy.map(function (gb) { return gb.split(":")[0]; }); diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js index dbfd9f0e211..5293409cb8b 100644 --- a/addons/web/static/src/js/views/graph/graph_view.js +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -31,7 +31,7 @@ var GraphView = AbstractView.extend({ Renderer: GraphRenderer, }), viewType: 'graph', - searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'], + searchMenuTypes: ['filter', 'groupBy', 'comparison', 'favorite'], /** * @override diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js index 1fca1e14487..7ff00d1fb77 100644 --- a/addons/web/static/src/js/views/pivot/pivot_view.js +++ b/addons/web/static/src/js/views/pivot/pivot_view.js @@ -30,7 +30,7 @@ odoo.define('web.PivotView', function (require) { Renderer: PivotRenderer, }), viewType: 'pivot', - searchMenuTypes: ['filter', 'groupBy', 'timeRange', 'favorite'], + searchMenuTypes: ['filter', 'groupBy', 'comparison', 'favorite'], /** * @override |
