From 333ac27cd5c23e3e1a4f7d78828f28a2c844f8f4 Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Fri, 6 Jul 2018 17:08:52 +0200 Subject: [PATCH] [IMP] web: new TimeRangeMenu widget in search view This commit introduces a new widget 'TimeRangeMenu' used in the search view. It allows a user to easily: - search data with a given date or datetime field value in a specified period of time - compare the data of interest with data of the previous period or the previous year. The time range menu is available in the search view when one of the four following view types is instantiated: graph, pivot, cohort, or dashboard. Task Id: 1835644 --- .../js/chrome/action_manager_act_window.js | 13 +- .../web/static/src/js/chrome/control_panel.js | 7 +- .../src/js/core/data_comparison_utils.js | 83 ++++++++ addons/web/static/src/js/core/domain.js | 111 ++++++++--- .../src/js/views/abstract_controller.js | 2 + .../web/static/src/js/views/abstract_view.js | 29 ++- .../static/src/js/views/pivot/pivot_model.js | 3 + .../src/js/views/search/filters_menu.js | 19 +- .../static/src/js/views/search/search_view.js | 170 +++++++++++++++- .../src/js/views/search/time_range_menu.js | 185 ++++++++++++++++++ .../views/search/time_range_menu_options.js | 33 ++++ addons/web/static/src/scss/dropdown_menu.scss | 1 + addons/web/static/src/scss/search_view.scss | 15 +- addons/web/static/src/xml/base.xml | 66 +++++++ addons/web/views/webclient_templates.xml | 3 + 15 files changed, 676 insertions(+), 64 deletions(-) create mode 100644 addons/web/static/src/js/core/data_comparison_utils.js create mode 100644 addons/web/static/src/js/views/search/time_range_menu.js create mode 100644 addons/web/static/src/js/views/search/time_range_menu_options.js diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js index 1ab4367de4c..2490cf0da57 100644 --- a/addons/web/static/src/js/chrome/action_manager_act_window.js +++ b/addons/web/static/src/js/chrome/action_manager_act_window.js @@ -528,15 +528,22 @@ ActionManager.include({ group_by_seq: groupbys || [], eval_context: this.userContext, }); + var groupBy = results.group_by.length ? + results.group_by : + (action.context.group_by || []); + groupBy = (typeof groupBy === 'string') ? [groupBy] : groupBy; + if (results.error) { throw new Error(_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s", JSON.stringify(results.error))); } - var groupBy = results.group_by.length ? results.group_by : (action.context.group_by || []); + + var context = _.omit(results.context, 'time_ranges'); + return { - context: results.context, + context: context, domain: results.domain, - groupBy: (typeof groupBy === 'string') ? [groupBy] : groupBy, + groupBy: groupBy, }; }, /** diff --git a/addons/web/static/src/js/chrome/control_panel.js b/addons/web/static/src/js/chrome/control_panel.js index fb265390f51..5d0601820d0 100644 --- a/addons/web/static/src/js/chrome/control_panel.js +++ b/addons/web/static/src/js/chrome/control_panel.js @@ -170,7 +170,7 @@ var ControlPanel = Widget.extend({ // Update the searchview and switch buttons if (status.searchview || options.clear) { - this._update_search_view(status.searchview, status.search_view_hidden, status.groupable); + this._update_search_view(status.searchview, status.search_view_hidden, status.groupable, status.enableTimeRangeMenu); } if (status.active_view_selector) { this._update_switch_buttons(status.active_view_selector); @@ -266,12 +266,15 @@ var ControlPanel = Widget.extend({ * @param {boolean} [groupable] visibility of the groupable menu (only * relevant if searchview is visible) */ - _update_search_view: function (searchview, isHidden, groupable) { + _update_search_view: function (searchview, isHidden, groupable, enableTimeRangeMenu) { if (searchview) { searchview.toggle_visibility(!isHidden); if (groupable !== undefined){ searchview.groupby_menu.do_toggle(groupable); } + if (enableTimeRangeMenu !== undefined){ + searchview.displayTimeRangeMenu(enableTimeRangeMenu); + } } this.nodes.$searchview.toggle(!isHidden); this.$el.toggleClass('o_breadcrumb_full', !!isHidden); diff --git a/addons/web/static/src/js/core/data_comparison_utils.js b/addons/web/static/src/js/core/data_comparison_utils.js new file mode 100644 index 00000000000..bdf74b56a24 --- /dev/null +++ b/addons/web/static/src/js/core/data_comparison_utils.js @@ -0,0 +1,83 @@ +odoo.define('web.dataComparisonUtils', function (require) { +"use strict"; + +var fieldUtils = require('web.field_utils'); + +/** + * @param {Number} value + * @param {Number} comparisonValue + * @returns {Object} + */ +function computeVariation (value, comparisonValue) { + var magnitude; + var signClass; + if (!isNaN(value) && !isNaN(comparisonValue)) { + if (comparisonValue === 0) { + if (value === 0) { + magnitude = 0; + } else if (value > 0){ + magnitude = 1; + } else { + magnitude = -1; + } + } else { + magnitude = Math.sign(comparisonValue) * (value - comparisonValue) / comparisonValue; + } + if (magnitude > 0) { + signClass = ' o_positive'; + } else if (magnitude < 0) { + signClass = ' o_negative'; + } else if (magnitude === 0) { + signClass = ' o_null'; + } + return {magnitude: magnitude, signClass: signClass}; + } else { + return {magnitude: NaN}; + } +} + +/** + * @param {Object} variation + * @param {Object} field + * @param {Object} options + * @returns {Object} + */ +function renderVariation (variation, field, options) { + var $variation; + if (!isNaN(variation.magnitude)) { + $variation = $('
', {class: 'o_variation' + variation.signClass}).html( + fieldUtils.format.percentage(variation.magnitude, field, options + )); + } else { + $variation = $('
', {class: 'o_variation'}).html('-'); + } + return $variation; +} + +/** + * @param {JQuery} $node + * @param {Number} value + * @param {Number} comparisonValue + * @param {Object} variation (with key 'magnitude' and 'signClass') + * @param {function} formatter + * @param {Object} field + * @param {Object} options + * @returns {Object} + */ +function renderComparison ($node, value, comparisonValue, variation, formatter, field, options) { + var $variation = renderVariation(variation, field, options); + $node.append($variation); + if (!isNaN(variation.magnitude)) { + $node.append( + $('
', {class: 'o_comparison'}) + .html(formatter(value, field, options) + ' vs ' + formatter(comparisonValue, field, options)) + ); + } +} + +return { + computeVariation: computeVariation, + renderComparison: renderComparison +}; + +}); diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js index c4011b049b9..5ebc6bd22c9 100644 --- a/addons/web/static/src/js/core/domain.js +++ b/addons/web/static/src/js/core/domain.js @@ -234,78 +234,127 @@ var Domain = collections.Tree.extend({ * @param {string} period * @param {string} type ('date' or 'datetime') * @param {boolean} forTooltip indicates if domain is used to print a tooltip + * @param {string} comparisonPeriod + * @returns {string} a domain in string form */ - constructDomain: function (fieldName, period, type, forTooltip) { + constructDomain: function (fieldName, period, type, forTooltip, comparisonPeriod) { var leftBoundaryParams, rightBoundaryParams; + var offsetPeriodParams; var t = forTooltip || false; function makeInterval () { + switch (comparisonPeriod) { + case 'previous_period': + _.map(offsetPeriodParams, function (value, key) { + if (_.isNumber(value)) { + leftBoundaryParams[key] = value + (leftBoundaryParams[key] || 0); + } else { + leftBoundaryParams[key] = value + ' + ' + (leftBoundaryParams[key] || 0); + } + }); + _.map(offsetPeriodParams, function (value, key) { + if (_.isNumber(value)) { + rightBoundaryParams[key] = value + (rightBoundaryParams[key] || 0); + } else { + rightBoundaryParams[key] = value + ' + ' + (rightBoundaryParams[key] || 0); + } + }); + break; + case 'previous_year': + leftBoundaryParams.years = leftBoundaryParams.years ? leftBoundaryParams.years-- : -1; + rightBoundaryParams.years = rightBoundaryParams.years ? rightBoundaryParams.years-- : -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(" + leftBoundaryParams + ")).strftime('%Y-%m-%d'))," + - "('" + fieldName + "', '<', (context_today() + relativedelta(" + rightBoundaryParams + ")).strftime('%Y-%m-%d'))"+ + "('" + 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(" + leftBoundaryParams + "), datetime.time(0,0,0)).to_utc()).strftime('%Y-%m-%d %H:%M:%S'))," + + "(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(" + rightBoundaryParams + "), datetime.time(0,0,0)).to_utc()).strftime('%Y-%m-%d %H:%M:%S'))"+ + "(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 = t ? "" : "days=1"; + leftBoundaryParams = {}; + rightBoundaryParams = t ? {} : {days: 1}; + offsetPeriodParams = {days: -1}; return makeInterval(); case 'this_week': - leftBoundaryParams = "weeks=-1, days=1, weekday=0"; - rightBoundaryParams = t ? "weekday=6" : "days=1, weekday=0"; + leftBoundaryParams = {weeks: -1, days: 1, weekday: 0}; + rightBoundaryParams = t ? {weekday: 6} : {days: 1, weekday: 0}; + offsetPeriodParams = {weeks: -1}; return makeInterval(); case 'this_month': - leftBoundaryParams = "day=1"; - rightBoundaryParams = (t ? "days=-1, " : "") + "day=1, months=1"; + leftBoundaryParams = {day: 1}; + rightBoundaryParams = _.extend(t ? {days: -1} : {}, + {day: 1, months: 1}); + offsetPeriodParams = {months: -1}; return makeInterval(); case 'this_quarter': - leftBoundaryParams = "months= - (context_today().month - 1) % 3, day=1"; - rightBoundaryParams = (t ? "days=-1, " : "") + "months= 3 - (context_today().month -1 ) % 3, day=1"; + leftBoundaryParams = {months: '- (context_today().month - 1) % 3', day: 1}; + rightBoundaryParams = _.extend(t ? {days: -1} : {}, + {months: '3 - (context_today().month - 1) % 3', day: 1}); + offsetPeriodParams = {months: -3}; return makeInterval(); case 'this_year': - leftBoundaryParams = "month=1, day=1"; - rightBoundaryParams = (t ? "days=-1, " : "") + "month=1, day=1, years=1"; + leftBoundaryParams = {month: 1, day: 1}; + rightBoundaryParams = _.extend(t ? {days: -1} : {}, + {month: 1, day: 1, years: 1}); + offsetPeriodParams = {years: -1}; return makeInterval(); case 'yesterday': - leftBoundaryParams = "days=-1"; - rightBoundaryParams = (t ? "days=-1" : ""); + leftBoundaryParams = {days: -1}; + rightBoundaryParams = (t ? {days: -1} : {}); + offsetPeriodParams = {days: -1}; return makeInterval(); case 'last_week': - leftBoundaryParams = "weeks=-2, days=1, weekday=0"; - rightBoundaryParams = t ? "weeks=-1, weekday=6" : "weeks=-1, days=1, weekday=0"; + leftBoundaryParams = {weeks: -2, days: 1, weekday: 0}; + rightBoundaryParams = t ? {weeks: -1, weekday: 6} : {weeks: -1, days: 1, weekday: 0}; + offsetPeriodParams = {weeks: -1}; return makeInterval(); case 'last_month': - leftBoundaryParams = "months=-1, day=1"; - rightBoundaryParams = (t ? "days=-1 , " : "") + "day=1"; + leftBoundaryParams = {months: -1, day: 1}; + rightBoundaryParams = _.extend(t ? {days: -1} : {}, {day: 1}); + offsetPeriodParams = {months: -1}; return makeInterval(); case 'last_quarter': - leftBoundaryParams = "months= - 3 - (context_today().month - 1) % 3, day=1"; - rightBoundaryParams = (t ? "days=-1 , " : "") + "months= - (context_today().month - 1) % 3, day=1"; + leftBoundaryParams = {months: '- 3 - (context_today().month - 1) % 3', day: 1}; + rightBoundaryParams = _.extend(t ? {days: -1} : {}, + {months: '- (context_today().month - 1) % 3', day: 1}); + offsetPeriodParams = {months: -3}; return makeInterval(); case 'last_year': - leftBoundaryParams = "month=1, day=1, years=-1"; - rightBoundaryParams = (t ? "days=-1 , " : "") + "month=1, day=1"; + leftBoundaryParams = {month: 1, day: 1, years: -1}; + rightBoundaryParams = _.extend(t ? {days: -1} : {}, {month: 1, day: 1}); + offsetPeriodParams = {years: -1}; return makeInterval(); case 'last_7_days': - leftBoundaryParams = "days=-7"; - rightBoundaryParams = t ? "days=-1" : ""; + leftBoundaryParams = {days: -7}; + rightBoundaryParams = t ? {days: -1} : {}; + offsetPeriodParams = {days: -7}; return makeInterval(); case 'last_30_days': - leftBoundaryParams = "days=-30"; - rightBoundaryParams = t ? "days=-1" : ""; + leftBoundaryParams = {days: -30}; + rightBoundaryParams = t ? {days: -1} : {}; + offsetPeriodParams = {days: -30}; return makeInterval(); case 'last_365_days': - leftBoundaryParams = "days=-365"; - rightBoundaryParams = t ? "days=-1" : ""; + leftBoundaryParams = {days: -365}; + rightBoundaryParams = t ? {days: -1} : {}; + offsetPeriodParams = {days: -365}; return makeInterval(); } }, diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index aed09c363cc..4bcb21f7a4d 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -67,6 +67,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { this.searchView = params.searchView; this.searchViewHidden = params.searchViewHidden; this.groupable = params.groupable; + this.enableTimeRangeMenu = params.enableTimeRangeMenu; this.actionViews = params.actionViews; this.viewType = params.viewType; this.withControlPanel = params.withControlPanel !== false; @@ -428,6 +429,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, { searchview: this.searchView, search_view_hidden: !this.searchable || this.searchviewHidden, groupable: this.groupable, + enableTimeRangeMenu: this.enableTimeRangeMenu, }); this._pushState(); diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index 7b1d21e6ea5..d061349b7c8 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -50,7 +50,7 @@ var AbstractView = Class.extend({ // groupby menu or not. This is useful for the views which do not support // grouping data. groupable: true, - + enableTimeRangeMenu: false, config: { Model: AbstractModel, Renderer: AbstractRenderer, @@ -74,6 +74,11 @@ var AbstractView = Class.extend({ * @param {number} [params.count] * @param {string} [params.controllerID] * @param {string[]} params.domain + * @param {string[][]} params.timeRange + * @param {string[][]} params.comparisonTimeRange + * @param {string} params.timeRangeDescription + * @param {string} params.comparisonTimeRangeDescription + * @param {boolean} params.compare * @param {string[]} params.groupBy * @param {number} [params.currentId] * @param {boolean} params.isEmbedded @@ -110,6 +115,22 @@ var AbstractView = Class.extend({ noContentHelp: params.action && params.action.help, }; + var timeRangeMenuData = params.context.timeRangeMenuData; + var timeRange = []; + var comparisonTimeRange = []; + var compare = false; + var timeRangeDescription = ""; + var comparisonTimeRangeDescription = ""; + if (this.enableTimeRangeMenu && timeRangeMenuData) { + timeRange = timeRangeMenuData.timeRange; + comparisonTimeRange = timeRangeMenuData.comparisonTimeRange; + compare = comparisonTimeRange.length > 0; + timeRangeDescription = timeRangeMenuData.timeRangeDescription; + comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription; + this.rendererParams.timeRangeDescription = timeRangeDescription; + this.rendererParams.comparisonTimeRangeDescription = comparisonTimeRangeDescription; + } + this.controllerParams = { modelName: params.modelName, activeActions: { @@ -119,6 +140,7 @@ var AbstractView = Class.extend({ duplicate: this.arch.attrs.duplicate ? JSON.parse(this.arch.attrs.duplicate) : true, }, groupable: this.groupable, + enableTimeRangeMenu: this.enableTimeRangeMenu, isEmbedded: this.isEmbedded, controllerID: params.controllerID, bannerRoute: this.arch.attrs.banner_route, @@ -144,6 +166,11 @@ var AbstractView = Class.extend({ count: params.count || ((this.controllerParams.ids !== undefined) && this.controllerParams.ids.length) || 0, domain: params.domain, + timeRange: timeRange, + timeRangeDescription: timeRangeDescription, + comparisonTimeRange: comparisonTimeRange, + comparisonTimeRangeDescription: comparisonTimeRangeDescription, + compare: compare, groupedBy: params.groupBy, modelName: params.modelName, res_id: params.currentId, diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index 153423fe115..0cbf095db70 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -18,10 +18,13 @@ odoo.define('web.PivotModel', function (require) { var AbstractModel = require('web.AbstractModel'); var concurrency = require('web.concurrency'); +var dataComparisonUtils = require('web.dataComparisonUtils'); var core = require('web.core'); var session = require('web.session'); var utils = require('web.utils'); +var computeVariation = dataComparisonUtils.computeVariation; + var _t = core._t; var PivotModel = AbstractModel.extend({ diff --git a/addons/web/static/src/js/views/search/filters_menu.js b/addons/web/static/src/js/views/search/filters_menu.js index af5425ec2d7..eb35a311fc9 100644 --- a/addons/web/static/src/js/views/search/filters_menu.js +++ b/addons/web/static/src/js/views/search/filters_menu.js @@ -7,10 +7,12 @@ var Domain = require('web.Domain'); var DropdownMenu = require('web.DropdownMenu'); var search_filters = require('web.search_filters'); var time = require('web.time'); +var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions'); var QWeb = core.qweb; var _t = core._t; +var PERIOD_OPTIONS = TimeRangeMenuOptions.PeriodOptions; var FiltersMenu = DropdownMenu.extend({ custom_events: { @@ -43,6 +45,7 @@ var FiltersMenu = DropdownMenu.extend({ * defaultOptionId: string refers to an optionId (optional) * currentOptionId: string refers to an optionId that is activated if item is active (optional) * } + * @param {Object[]} periodOptions determines the options available for filter with date attribute * @param {Object} fields 'field_get' of a model: mapping from field name * to an object with its properties */ @@ -50,21 +53,7 @@ var FiltersMenu = DropdownMenu.extend({ // determines where the filter menu is displayed and its style this.isMobile = config.device.isMobile; // determines list of options used by filter of type 'date' - this.periodOptions = [ - {description: _t('Today'), optionId: 'today', groupId: 1}, - {description: _t('This Week'), optionId: 'this_week', groupId: 1}, - {description: _t('This Month'), optionId: 'this_month', groupId: 1}, - {description: _t('This Quarter'), optionId: 'this_quarter', groupId: 1}, - {description: _t('This Year'), optionId: 'this_year', groupId: 1}, - {description: _t('Yesterday'), optionId: 'yesterday', groupId: 2}, - {description: _t('Last Week'), optionId: 'last_week', groupId: 2}, - {description: _t('Last Month'), optionId: 'last_month', groupId: 2}, - {description: _t('Last Quarter'), optionId: 'last_quarter', groupId: 2}, - {description: _t('Last Year'), optionId: 'last_year', groupId: 2}, - {description: _t('Last 7 Days'), optionId: 'last_7_days', groupId: 3}, - {description: _t('Last 30 Days'), optionId: 'last_30_days', groupId: 3}, - {description: _t('Last 365 Days'), optionId: 'last_365_days', groupId: 3}, - ]; + this.periodOptions = PERIOD_OPTIONS; // determines when the 'Add custom filter' submenu is open this.generatorMenuIsOpen = false; this.propositions = []; diff --git a/addons/web/static/src/js/views/search/search_view.js b/addons/web/static/src/js/views/search/search_view.js index 2bac31ed27f..8e79b6b057e 100644 --- a/addons/web/static/src/js/views/search/search_view.js +++ b/addons/web/static/src/js/views/search/search_view.js @@ -10,9 +10,14 @@ var FiltersMenu = require('web.FiltersMenu'); var GroupByMenu = require('web.GroupByMenu'); var pyUtils = require('web.py_utils'); var search_inputs = require('web.search_inputs'); +var TimeRangeMenu = require('web.TimeRangeMenu'); +var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions'); var utils = require('web.utils'); var Widget = require('web.Widget'); + var _t = core._t; +var ComparisonOptions = TimeRangeMenuOptions.ComparisonOptions; +var PeriodOptions = TimeRangeMenuOptions.PeriodOptions; var Backbone = window.Backbone; @@ -198,7 +203,7 @@ var FacetView = Widget.extend({ * is the current interval used * (necessarily the field is of type 'date' or 'datetime') * @param {Object} periodMapping, a key is a field name and the corresponding value - * is the current interval used + * is the current period used * (necessarily the field is of type 'date' or 'datetime') */ init: function (parent, model, intervalMapping, periodMapping) { @@ -248,9 +253,11 @@ var FacetValueView = Widget.extend({ /* * @param {Widget} parent * @param {Object} model - * @param {Object} interval (optional) is used in case the facet value + * @param {Object} option (optional) is used in case the facet value * corresponds to a groupby with an associated 'date' * 'datetime' field. + * @param {Object} comparison (optional) is used in case the facet value + * comes from the time range menu and comparison is active */ init: function (parent, model, option) { this._super(parent); @@ -326,13 +333,16 @@ var SearchView = Widget.extend({ item_option_changed: '_onItemOptionChanged', new_groupby: '_onNewGroupby', new_filters: '_onNewFilters', + time_range_modified: '_onTimeRangeModified', + time_range_removed: '_onTimeRangeRemoved', }, defaults: _.extend({}, Widget.prototype.defaults, { hidden: false, - disable_filters: false, + disable_custom_filters: false, disable_groupby: false, disable_favorites: false, - disable_custom_filters: false, + disable_filters: false, + disableTimeRangeMenu: true, }), template: "SearchView", @@ -361,6 +371,15 @@ var SearchView = Widget.extend({ this.hasFavorites = false; + this.noDateFields = true; + var field; + for (var key in this.fields) { + field = this.fields[key]; + if (_.contains(['date', 'datetime'], field.type) && field.sortable) { + this.noDateFields = false; + break; + } + } this.selectedGroupIds = { groupByCategory: [], filterCategory: [] @@ -373,6 +392,8 @@ var SearchView = Widget.extend({ this.filters = []; this.groupbys = []; + this.timeRanges = options.action && options.action.context ? + options.action.context.time_ranges : undefined; var visibleSearchMenu = this.call('local_storage', 'getItem', 'visible_search_menu'); this.visible_filters = (visibleSearchMenu !== 'false'); this.input_subviews = []; // for user input in searchbar @@ -421,6 +442,12 @@ var SearchView = Widget.extend({ .then(this.set_default_filters.bind(this)) .then(function () { var menu_defs = []; + self.timeRangeMenu = self._createTimeRangeMenu(); + menu_defs.push(self.timeRangeMenu.prependTo($buttons)); + self.timeRangeMenu.do_hide(); + var display = self.options.disableTimeRangeMenu !== undefined && + !self.options.disableTimeRangeMenu; + self.displayTimeRangeMenu(display); if (!self.options.disable_groupby) { self.groupby_menu = self._createGroupByMenu(); menu_defs.push(self.groupby_menu.prependTo($buttons)); @@ -432,6 +459,17 @@ var SearchView = Widget.extend({ return $.when.apply($, menu_defs); }); }, + /* + * + * @param {boolean} + */ + displayTimeRangeMenu: function (b) { + if (!b || this.noDateFields) { + this.timeRangeMenu.do_hide(); + } else { + this.timeRangeMenu.do_show(); + } + }, on_attach_callback: function () { this._focusInput(); }, @@ -448,6 +486,8 @@ var SearchView = Widget.extend({ if (!_.isEmpty(this.search_defaults)) { var inputs = this.search_fields.concat(this.filters, this.groupbys); var search_defaults = _.invoke(inputs, 'facet_for_defaults', this.search_defaults); + var defaultTimeRange = this._searchDefaultTimeRange(); + search_defaults.push(defaultTimeRange); return $.when.apply(null, search_defaults).then(function () { var facets = _.compact(arguments); self.query.reset(facets, {preventSearch: true}); @@ -502,7 +542,7 @@ var SearchView = Widget.extend({ if (domain) { domains.push(domain); } - var context = field.get_context(facet); + var context = field.get_context(facet, noDomainEvaluation); if (context) { contexts.push(context); } @@ -630,8 +670,8 @@ var SearchView = Widget.extend({ groupByCategory: [], filterCategory: [], }; + var timeRangeMenuIsActive; this.query.each(function (facet) { - var values = facet.get('values'); if (facet.attributes.cat === "groupByCategory") { selectedGroupIds.groupByCategory = _.uniq( @@ -665,7 +705,9 @@ var SearchView = Widget.extend({ ) ); } - + if (facet.attributes.cat === "timeRangeCategory") { + timeRangeMenuIsActive = true; + } var f = new FacetView(this, facet, this.intervalMapping, this.periodMapping); started.push(f.appendTo(self.$('.o_searchview_input_container'))); self.input_subviews.push(f); @@ -692,6 +734,9 @@ var SearchView = Widget.extend({ if (self.filters_menu) { self._unsetUnusedFilters(selectedGroupIds.filterCategory); } + if (self.timeRangeMenu && !timeRangeMenuIsActive) { + self.timeRangeMenu.deactivate(); + } }); }, childFocused: function () { @@ -747,7 +792,7 @@ var SearchView = Widget.extend({ } var current_group = [], current_category = 'filters', - categories = {filters: this.filters, group_by: this.groupbys}; + categories = {filters: this.filters, group_by: this.groupbys, timeRanges: this.timeRanges}; _.each(filters.concat({category:'filters', item: 'separator'}), function (filter) { if (filter.item.tag === 'filter' && filter.category === current_category) { @@ -921,6 +966,15 @@ var SearchView = Widget.extend({ }); return new GroupByMenu(this, groupbys, this.fields); }, + /** + * Create a time range menu. + * + * @private + * @returns {Widget} the range menu + */ + _createTimeRangeMenu: function () { + return new TimeRangeMenu(this, this.fields, this.timeRanges); + }, /** * Processes a fieldsView in place. In particular, parses its arch. * @@ -936,6 +990,81 @@ var SearchView = Widget.extend({ fv.arch = utils.xml_to_json(doc, true); return fv; }, + _searchDefaultTimeRange: function () { + if (this.timeRanges) { + var timeRange = "[]"; + var timeRangeDescription; + var comparisonTimeRange = "[]"; + var comparisonTimeRangeDescription; + + var dateField = { + name: this.timeRanges.field, + type: this.fields[this.timeRanges.field].type, + description: this.fields[this.timeRanges.field].string, + }; + + timeRange = Domain.prototype.constructDomain( + dateField.name, + this.timeRanges.range, + dateField.type + ); + timeRangeDescription = _.findWhere( + PeriodOptions, + {optionId: this.timeRanges.range} + ).description; + + if (this.timeRanges.comparison_range) { + comparisonTimeRange = Domain.prototype.constructDomain( + dateField.name, + this.timeRanges.range, + dateField.type, + null, + this.timeRanges.comparison_range + ); + comparisonTimeRangeDescription = _.findWhere( + ComparisonOptions, + {optionId: this.timeRanges.comparison_range} + ).description; + } + + return $.when({ + cat: 'timeRangeCategory', + category: _t("Time Range"), + icon: 'fa fa-calendar', + field: { + get_context: function (facet, noDomainEvaluation) { + if (!noDomainEvaluation) { + timeRange = Domain.prototype.stringToArray(timeRange); + comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange); + } + return { + timeRangeMenuData: { + timeRange: timeRange, + timeRangeDescription: timeRangeDescription, + comparisonTimeRange: comparisonTimeRange, + comparisonTimeRangeDescription: comparisonTimeRangeDescription, + } + }; + }, + get_groupby: function () {}, + get_domain: function () {} + }, + isRange: true, + values: [{ + label: dateField.description + ': ' + timeRangeDescription + + ( + comparisonTimeRangeDescription ? + (' / ' + comparisonTimeRangeDescription) : + '' + ), + value: null, + }], + }); + } else { + return $.when(); + } + }, + /** * This function ask the groupby menu to deactive all groupbys if no * groupby is used. @@ -1115,6 +1244,31 @@ var SearchView = Widget.extend({ this.query.trigger('reset'); } }, + /* + * @private + * @param {JQueryEvent} event + */ + _onTimeRangeModified: function () { + var facet = this.timeRangeMenu.facetFor(); + var current = this.query.find(function (facet) { + return facet.get('cat') === 'timeRangeCategory'; + }); + if (current) { + this.query.remove(current, {silent: true}); + } + this.query.add(facet); + }, + /* + * @private + */ + _onTimeRangeRemoved: function () { + var current = this.query.find(function (facet) { + return facet.get('cat') === 'timeRangeCategory'; + }); + if (current) { + this.query.remove(current); + } + }, }); _.extend(SearchView, { diff --git a/addons/web/static/src/js/views/search/time_range_menu.js b/addons/web/static/src/js/views/search/time_range_menu.js new file mode 100644 index 00000000000..7da0a7da8fe --- /dev/null +++ b/addons/web/static/src/js/views/search/time_range_menu.js @@ -0,0 +1,185 @@ +odoo.define('web.TimeRangeMenu', function (require) { +"use strict"; + +var core = require('web.core'); +var Domain = require('web.Domain'); +var TimeRangeMenuOptions = require('web.TimeRangeMenuOptions'); +var Widget = require('web.Widget'); + +var _t = core._t; +var ComparisonOptions = TimeRangeMenuOptions.ComparisonOptions; +var PeriodOptions = TimeRangeMenuOptions.PeriodOptions; + +var TimeRangeMenu = Widget.extend({ + template: 'web.TimeRangeMenu', + events: { + 'click .o_apply_range': '_onApplyButtonClick', + 'click .o_comparison_checkbox': '_onCheckBoxClick', + }, + + /** + * override + * @param {Widget} parent + * @param {Object} fields + * @param {Object} configuration + * + */ + init: function(parent, fields, configuration) { + var self = this; + this._super(parent); + this.dateFields = []; + _.each(fields, function (field, name) { + if (field.sortable && _.contains(['date', 'datetime'], field.type)) { + self.dateFields.push(_.extend({}, field, { + name: name, + })); + } + }); + this.periodOptions = PeriodOptions; + this.comparisonOptions = ComparisonOptions; + + // Following steps determine initial configuration + this.isActive = false; + this.timeRangeId = undefined; + this.comparisonIsSelected = false; + this.comparisonTimeRangeId = undefined; + this.dateField = {}; + if (configuration && configuration.field && configuration.range) { + this.isActive = true; + var dateField = _.findWhere(this.dateFields, {name: configuration.field}); + this.dateField = { + name: dateField.name, + description: dateField.string, + type: dateField.type, + }; + this.timeRangeId = configuration.range; + if (configuration.comparison_range) { + this.comparisonIsSelected = true; + this.comparisonTimeRangeId = configuration.comparison_range; + } + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + deactivate: function () { + this.isActive = false; + this.comparisonIsSelected = false; + this.renderElement(); + }, + /** + * Generates a :js:class:`~instance.web.search.Facet` descriptor from a + * filter descriptor + * + * @returns {Object} + */ + facetFor: function () { + var fieldDescription; + var timeRange = "[]"; + var timeRangeDescription; + var comparisonTimeRange = "[]"; + var comparisonTimeRangeDescription; + + if (this.isActive) { + fieldDescription = this.dateField.description; + if (this.timeRangeId !== 'custom') { + timeRange = Domain.prototype.constructDomain( + this.dateField.name, + this.timeRangeId, + this.dateField.type + ); + timeRangeDescription = _.findWhere( + this.periodOptions, + {optionId: this.timeRangeId} + ).description; + } + if (this.comparisonIsSelected) { + comparisonTimeRange = Domain.prototype.constructDomain( + this.dateField.name, + this.timeRangeId, + this.dateField.type, + null, + this.comparisonTimeRangeId + ); + comparisonTimeRangeDescription = _.findWhere( + this.comparisonOptions, + {optionId: this.comparisonTimeRangeId} + ).description; + } + } + + return { + cat: 'timeRangeCategory', + category: _t("Time Range"), + icon: 'fa fa-calendar', + field: { + get_context: function (facet, noDomainEvaluation) { + if (!noDomainEvaluation) { + timeRange = Domain.prototype.stringToArray(timeRange); + comparisonTimeRange = Domain.prototype.stringToArray(comparisonTimeRange); + } + return { + timeRangeMenuData: { + timeRange: timeRange, + timeRangeDescription: timeRangeDescription, + comparisonTimeRange: comparisonTimeRange, + comparisonTimeRangeDescription: comparisonTimeRangeDescription, + } + }; + }, + get_groupby: function () {}, + get_domain: function () {} + }, + isRange: true, + values: [{ + label: fieldDescription + ': ' + timeRangeDescription + + ( + comparisonTimeRangeDescription ? + (' / ' + comparisonTimeRangeDescription) : + '' + ), + value: null, + }], + }; + }, + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onApplyButtonClick: function () { + this.isActive = true; + var dateFieldName = this.$('.o_date_field_selector').val(); + this.timeRangeId = this.$('.o_time_range_selector').val(); + if (this.comparisonIsSelected) { + this.comparisonTimeRangeId = this.$('.o_comparison_time_range_selector').val(); + } + this.dateField = { + name: dateFieldName, + type: _.findWhere(this.dateFields, {name: dateFieldName}).type, + description: _.findWhere(this.dateFields, {name: dateFieldName}).string, + }; + + this.renderElement(); + this.trigger_up('time_range_modified'); + }, + /** + * @private + * + * @param {JQueryEvent} ev + */ + _onCheckBoxClick: function (ev) { + ev.stopPropagation(); + this.comparisonIsSelected = this.$('.o_comparison_checkbox').prop('checked'); + this.$('.o_comparison_time_range_selector').toggleClass('o_hidden'); + this.$el.addClass('open'); + } +}); + +return TimeRangeMenu; + +}); diff --git a/addons/web/static/src/js/views/search/time_range_menu_options.js b/addons/web/static/src/js/views/search/time_range_menu_options.js new file mode 100644 index 00000000000..1a5510ab6c4 --- /dev/null +++ b/addons/web/static/src/js/views/search/time_range_menu_options.js @@ -0,0 +1,33 @@ +odoo.define('web.TimeRangeMenuOptions', function (require) { +"use strict"; + +var core = require('web.core'); +var _lt = core._lt; + +var PeriodOptions = [ + {description: _lt('Today'), optionId: 'today', groupId: 1}, + {description: _lt('This Week'), optionId: 'this_week', groupId: 1}, + {description: _lt('This Month'), optionId: 'this_month', groupId: 1}, + {description: _lt('This Quarter'), optionId: 'this_quarter', groupId: 1}, + {description: _lt('This Year'), optionId: 'this_year', groupId: 1}, + {description: _lt('Yesterday'), optionId: 'yesterday', groupId: 2}, + {description: _lt('Last Week'), optionId: 'last_week', groupId: 2}, + {description: _lt('Last Month'), optionId: 'last_month', groupId: 2}, + {description: _lt('Last Quarter'), optionId: 'last_quarter', groupId: 2}, + {description: _lt('Last Year'), optionId: 'last_year', groupId: 2}, + {description: _lt('Last 7 Days'), optionId: 'last_7_days', groupId: 3}, + {description: _lt('Last 30 Days'), optionId: 'last_30_days', groupId: 3}, + {description: _lt('Last 365 Days'), optionId: 'last_365_days', groupId: 3}, +]; + +var ComparisonOptions = [ + {description: _lt('Previous Period'), optionId: 'previous_period'}, + {description: _lt('Previous Year'), optionId: 'previous_year'} +]; + +return { + PeriodOptions: PeriodOptions, + ComparisonOptions: ComparisonOptions, +}; + +}); diff --git a/addons/web/static/src/scss/dropdown_menu.scss b/addons/web/static/src/scss/dropdown_menu.scss index 8f11ba7c0a1..0ff7fd7f561 100644 --- a/addons/web/static/src/scss/dropdown_menu.scss +++ b/addons/web/static/src/scss/dropdown_menu.scss @@ -3,6 +3,7 @@ display: none; } + min-width: 150px; max-height: calc(100vh - 125px); // FIXME overflow: auto; diff --git a/addons/web/static/src/scss/search_view.scss b/addons/web/static/src/scss/search_view.scss index fc1d84d8247..d5149134304 100644 --- a/addons/web/static/src/scss/search_view.scss +++ b/addons/web/static/src/scss/search_view.scss @@ -142,11 +142,13 @@ } } -// Filters -.o_filters_menu { +.o_filters_menu, .o_group_by_menu, .o_time_range_menu { max-height: calc(100vh - 125px); overflow: auto; +} +// Filters +.o_filters_menu { .o_filter_condition { @include o-search-options-dropdown-custom-item; margin-bottom: 8px; @@ -182,8 +184,6 @@ } } - - // Favorites .o_favorites_menu { max-height: 75vh; @@ -211,3 +211,10 @@ @include o-search-options-dropdown-custom-item; } } + +// Time Ranges +.o_time_range_menu { + .dropdown-item-text > label, .custom-control-label { + font-weight: bold; + } +} diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 12464d2220e..c5581976e30 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -1127,6 +1127,72 @@
+
+ + +