From be5a51a345b8986da0e19627260f64f984e11ae9 Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Mon, 25 May 2020 18:11:02 +0000 Subject: [IMP] web: Comparison menu This commit replaces the "time range menu" in the control panel by a new component: the "comparison menu". Its purpose is roughly the same: compare a period of data in a reporting view with the previous period or the previous year. Its implementation is a bit simpler: it will only appear if at least one date filter is selected in the search view, and will suggest to compare each selected date option with the previous period or the previous year. Task ID: 2245719 Co-authored-by: Mathieu Duckerts-Antoine Co-authored-by: Julien Mougenot --- .../static/src/js/control_panel/search_utils.js | 477 ++++++++++++++++++--- 1 file changed, 428 insertions(+), 49 deletions(-) (limited to 'addons/web/static/src/js/control_panel/search_utils.js') 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, }; }); -- cgit v1.2.3