diff options
| -rw-r--r-- | addons/web/static/src/js/core/data_comparison_utils.js | 106 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_controller.js | 23 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_model.js | 39 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_renderer.js | 1160 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_view.js | 9 | ||||
| -rw-r--r-- | addons/web/static/src/scss/graph_view.scss | 59 | ||||
| -rw-r--r-- | addons/web/static/src/xml/base.xml | 3 | ||||
| -rw-r--r-- | addons/web/static/src/xml/chart.xml | 29 | ||||
| -rw-r--r-- | addons/web/static/tests/core/data_comparison_utils_tests.js | 75 | ||||
| -rw-r--r-- | addons/web/static/tests/views/graph_tests.js | 1177 | ||||
| -rw-r--r-- | addons/web/views/webclient_templates.xml | 1 |
11 files changed, 1775 insertions, 906 deletions
diff --git a/addons/web/static/src/js/core/data_comparison_utils.js b/addons/web/static/src/js/core/data_comparison_utils.js index 60f88e67d8d..b55cc4b93d8 100644 --- a/addons/web/static/src/js/core/data_comparison_utils.js +++ b/addons/web/static/src/js/core/data_comparison_utils.js @@ -5,82 +5,64 @@ var fieldUtils = require('web.field_utils'); var Class = require('web.Class'); var DateClasses = Class.extend({ - init: function (dateSets, interval) { - // dateSets are assumed pairwise disjoint and ordered naturally. - // at least one of them is non empty. - - // we complete the first inhabited dateSet. Its elements will be - // the default representatives for the classes. - this._maximalLength = 1; - this._referenceIndex = null; + /** + * This small class offers a light API to manage equivalence classes of + * dates. Two dates in different dateSets are declared equivalent when + * their indexes are equal. + * + * @param {Array[]} dateSets, a list of list of dates + */ + init: function (dateSets) { + // At least one dateSet must be non empty. + // The completion of the first inhabited dateSet will serve as a reference set. + // The reference set elements will be the default representatives for the classes. + this.dateSets = dateSets; + this.referenceIndex = null; for (var i = 0; i < dateSets.length; i++) { var dateSet = dateSets[i]; - if (dateSet.length && this._referenceIndex === null) { - this._referenceIndex = i; - } - if (dateSet.length > this._maximalLength) { - this._maximalLength = dateSet.length; + if (dateSet.length && this.referenceIndex === null) { + this.referenceIndex = i; } } - this._referenceSet = this._contructReferenceSet(dateSets[this._referenceIndex], interval); - this._dateClasses = this._constructDateClasses(dateSets); }, //---------------------------------------------------------------------- // Public //---------------------------------------------------------------------- - representative: function (date, index) { - index = index || this._referenceIndex; - return this._dateClass(date)[index]; - }, - - //---------------------------------------------------------------------- - // Private - //---------------------------------------------------------------------- - /** - * @param {moment[]} dateSet, ordered dateSet - * @param {number} num, indicates number of dates to add - * @param {string} interval, determines time interval between two added dates - * @returns {function} + * Returns the index of a date in a given datesetIndex. This can be considered + * as the date class itself. + * + * @param {number} datesetIndex + * @param {string} date + * @return {number} */ - _contructReferenceSet: function (dateSet, interval) { - var additionalDates = []; - var dateSetLength = dateSet.length; - var diff = this._maximalLength - dateSetLength; - var lastDate = dateSet[dateSetLength - 1]; - for (var i = 0; i < diff; i++) { - var date = moment(lastDate).add(i + 1, interval).format('DD MMM YYYY'); - additionalDates.push(date); - } - return dateSet.concat(additionalDates); + dateClass: function (datesetIndex, date) { + return this.dateSets[datesetIndex].indexOf(date); }, - _constructDateClasses: function (dateSets) { - var dateClasses = []; - for (var index = 0; index < this._maximalLength; index++) { - var dateClass = []; - for (var j = 0; j < dateSets.length; j++) { - var dateSet = j=== this._referenceIndex ? this._referenceSet : dateSets[j]; - if (index < dateSet.length) { - dateClass.push(dateSet[index]); - } else { - dateClass.push(undefined); - } - } - dateClasses.push(dateClass); - } - return dateClasses; + /** + * returns the dates occuring in a given class + * + * @param {number} dateClass + * @return {string[]} + */ + dateClassMembers: function (dateClass) { + return _.uniq(_.compact(this.dateSets.map(function (dateSet) { + return dateSet[dateClass]; + }))); }, - _dateClass: function (date) { - var dateClass; - for (var i = 0; i < this._dateClasses.length; i++) { - dateClass = this._dateClasses[i]; - if (_.contains(dateClass, date)) { - break; - } - } - return dateClass; + /** + * return the representative of a date class from a date set specified by an + * index. + * + * @param {number} dateClass + * @param {number} [index] + * @return {string} + */ + representative: function (dateClass, index) { + index = index === undefined ? this.referenceIndex : index; + return this.dateSets[index][dateClass]; }, }); /** diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js index a88a94b786f..9e2ebfc0a78 100644 --- a/addons/web/static/src/js/views/graph/graph_controller.js +++ b/addons/web/static/src/js/views/graph/graph_controller.js @@ -114,12 +114,11 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ _setGroupby: function (groupBy) { this.update({groupBy: groupBy}); }, - /** * @todo remove this and directly calls update. Update should be overridden * and modified to call _updateButtons * - * private + * @private * * @param {'pie'|'line'|'bar'} mode */ @@ -139,6 +138,15 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ }); }, /** + * @private + * + * @param {boolean} stacked + */ + _toggleStackMode: function (stacked) { + this.update({stacked: stacked}); + this._updateButtons(); + }, + /** * override * * @private @@ -160,6 +168,11 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ this.$buttons .find('.o_graph_button[data-mode="' + state.mode + '"]') .addClass('active'); + this.$buttons + .find('.o_graph_button[data-mode="stack"]') + .data('stacked', state.stacked) + .toggleClass('active', state.stacked) + .toggleClass('o_hidden', state.mode !== 'bar'); _.each(this.$measureList.find('.dropdown-item'), function (item) { var $item = $(item); $item.toggleClass('selected', $item.data('field') === state.measure); @@ -180,7 +193,11 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ var $target = $(ev.target); var field; if ($target.hasClass('o_graph_button')) { - this._setMode($target.data('mode')); + if (_.contains(['bar','line', 'pie'], $target.data('mode'))) { + this._setMode($target.data('mode')); + } else if ($target.data('mode') === 'stack') { + this._toggleStackMode(!$target.data('stacked')); + } } else if ($target.parents('.o_graph_measures_list').length) { ev.preventDefault(); ev.stopPropagation(); 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 33b8896b29d..0ab8d4c0570 100644 --- a/addons/web/static/src/js/views/graph/graph_model.js +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -25,12 +25,18 @@ return AbstractModel.extend({ //-------------------------------------------------------------------------- /** - * override * + * We defend against outside modifications by extending the chart data. It + * may be overkill. + * + * @override * @returns {Object} */ get: function () { - return this.chart; + var self = this; + return _.extend({}, this.chart, { + comparisonFieldIndex: self._getComparisonFieldIndex(), + }); }, /** * Initial loading. @@ -44,7 +50,9 @@ return AbstractModel.extend({ * @param {Object} params.fields * @param {string[]} params.comparisonTimeRange * @param {string[]} params.domain - * @param {string[]} params.groupBy a list of valid field names + * @param {string[]} params.groupBys a list of valid field names + * @param {string[]} params.groupedBy a list of valid field names + * @param {boolean} params.stacked * @param {string[]} params.timeRange * @param {string} params.comparisonField * @param {string} params.comparisonTimeRangeDescription @@ -72,6 +80,7 @@ return AbstractModel.extend({ measure: params.context.graph_measure || params.measure, mode: params.context.graph_mode || params.mode, origins: [], + stacked: params.stacked, timeRange: params.timeRange, timeRangeDescription: params.timeRangeDescription, }; @@ -86,6 +95,7 @@ return AbstractModel.extend({ * * @param {any} handle ignored! * @param {Object} params + * @param {boolean} [params.stacked] * @param {Object} [params.context] * @param {string[]} [params.domain] * @param {string[]} [params.groupBy] @@ -129,6 +139,10 @@ return AbstractModel.extend({ this.chart.mode = params.mode; return Promise.resolve(); } + if ('stacked' in params) { + this.chart.stacked = params.stacked; + return Promise.resolve(); + } return this._loadGraph(this._getDomains()); }, @@ -137,24 +151,35 @@ return AbstractModel.extend({ //-------------------------------------------------------------------------- /** - * @returns {Object[]} + * @private + * @returns {number} + */ + _getComparisonFieldIndex: function () { + var groupBys = this.chart.groupBy.map(function (gb) { + return gb.split(":")[0]; + }); + return groupBys.indexOf(this.chart.comparisonField); + }, + /** + * @private + * @returns {Array[]} */ _getDomains: function () { var domains = [this.chart.domain.concat(this.chart.timeRange)]; - this.chart.origins = [this.chart.timeRangeDescription]; + this.chart.origins = [this.chart.timeRangeDescription || ""]; if (this.chart.compare) { domains.push(this.chart.domain.concat(this.chart.comparisonTimeRange)); this.chart.origins.push(this.chart.comparisonTimeRangeDescription); } return domains; }, - /** * Fetch and process graph data. It is basically a(some) read_group(s) * with correct fields for each domain. We have to do some light processing * to separate date groups in the field list, because they can be defined * with an aggregation function, such as my_date:week. * + * @private * @param {Array[]} domains * @returns {Promise} */ @@ -200,6 +225,7 @@ return AbstractModel.extend({ * the object this.chart in argument, or an array or something. We want to * avoid writing on a this.chart object modified by a subsequent read_group * + * @private * @param {number} originIndex * @param {any} rawData result from the read_group */ @@ -239,6 +265,7 @@ return AbstractModel.extend({ * Helper function (for _processData), turns various values in a usable * string form, that we can display in the interface. * + * @private * @param {any} value value for the field fieldName received by the read_group rpc * @param {string} fieldName * @returns {string} diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js index 34d0ed4eb80..02fdb102358 100644 --- a/addons/web/static/src/js/views/graph/graph_renderer.js +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -3,27 +3,55 @@ odoo.define('web.GraphRenderer', function (require) { /** * The graph renderer turns the data from the graph model into a nice looking - * svg chart. This code uses the nvd3 library. - * - * Note that we use a custom build for the nvd3, with only the model we actually - * use. + * canvas chart. This code uses the Chart.js library. */ var AbstractRenderer = require('web.AbstractRenderer'); var config = require('web.config'); var core = require('web.core'); -var field_utils = require('web.field_utils'); var dataComparisonUtils = require('web.dataComparisonUtils'); +var fieldUtils = require('web.field_utils'); var _t = core._t; -var qweb = core.qweb; var DateClasses = dataComparisonUtils.DateClasses; +var qweb = core.qweb; var CHART_TYPES = ['pie', 'bar', 'line']; +var COLORS = ["#1f77b4","#ff7f0e","#aec7e8","#ffbb78","#2ca02c","#98df8a","#d62728", + "#ff9896","#9467bd","#c5b0d5","#8c564b","#c49c94","#e377c2","#f7b6d2", + "#7f7f7f","#c7c7c7","#bcbd22","#dbdb8d","#17becf","#9edae5"]; +var COLOR_NB = COLORS.length; +function hexToRGBA (hex, opacity) { + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + var rgb = result.slice(1, 4).map(function (n) { + return parseInt(n, 16); + }).join(','); + return 'rgba(' + rgb + ',' + opacity + ')'; +} + +// used to format values in tooltips and yAxes. +var FORMAT_OPTIONS = { + // allow to decide if utils.human_number should be used + humanReadable: function (value) { + return Math.abs(value) >= 1000; + }, + // with the choices below, 1236 is represented by 1.24k + minDigits: 1, + decimals: 2, + // avoid comma separators for thousands in numbers when human_number is used + formatterCallback: function (str) { + return str; + }, +}; + + +var NO_DATA = [_t('No data')]; +NO_DATA.isNoData = true; + // hide top legend when too many items for device size -var MAX_LEGEND_LENGTH = 25 * (Math.max(1, config.device.size_class)); -var SPLIT_THRESHOLD = config.device.isMobile ? Infinity : 20; +var MAX_LEGEND_LENGTH = 4 * (Math.max(1, config.device.size_class)); +var MAX_TOOLTIPS_LENGTH = 4 * (Math.max(1, config.device.size_class)); return AbstractRenderer.extend({ className: "o_graph_renderer", @@ -32,38 +60,28 @@ return AbstractRenderer.extend({ * @param {Widget} parent * @param {Object} state * @param {Object} params - * @param {boolean} params.stacked + * @param {boolean} [params.isEmbedded] + * @param {Object} [params.fields] + * @param {string} [params.title] */ init: function (parent, state, params) { this._super.apply(this, arguments); - this.datasets = []; - this.isEmbedded = params.isEmbedded; - this.stacked = this.state.compare ? false : params.stacked; + this.isEmbedded = params.isEmbedded || false; this.title = params.title || ''; this.fields = params.fields || {}; - this.formatOptions = { - // allow to decide if utils.human_number should be used - humanReadable: function (value) { - return Math.abs(value) >= 1000; - }, - // with the choices below, 1236 is represented by 1.24k - minDigits: 1, - decimals: 2, - // avoid comma separators for thousands in numbers when human_number is used - formatterCallback: function (str) { - return str; - } - }; + + this.chart = null; + this.chartId = _.uniqueId('chart'); }, /** * @override */ - destroy: function () { - nv.utils.offWindowResize(this.to_remove); - this._super(); + updateState: function () { + + return this._super.apply(this, arguments); }, /** - * The graph view uses the nv(d3) lib to render the graph. This lib requires + * The graph view uses the Chart.js lib to render the graph. This lib requires * that the rendering is done directly into the DOM (so that it can correctly * compute positions). However, the views are always rendered in fragments, * and appended to the DOM once ready (to prevent them from flickering). We @@ -87,38 +105,502 @@ return AbstractRenderer.extend({ }, //-------------------------------------------------------------------------- - // Public + // Private //-------------------------------------------------------------------------- /** - * @override - * @param {Object} state - * @param {Object} params + * Filter out some dataPoints because they would lead to bad graphics. + * The filtering is done with respect to the graph view mode. + * Note that the method does not alter this.state.dataPoints, since we + * want to be able to change of mode without fetching data again: + * we simply present the same data in a different way. + * + * @private + * @returns {Object[]} */ - updateState: function (state, params) { - this.stacked = this.state.compare ? false : params.stacked; - return this._super.apply(this, arguments); + _filterDataPoints: function () { + var dataPoints = []; + if (_.contains(['bar', 'pie'], this.state.mode)) { + dataPoints = this.state.dataPoints.filter(function (dataPt) { + return dataPt.count > 0; + }); + } else if (this.state.mode === 'line') { + var counts = 0; + this.state.dataPoints.forEach(function (dataPt) { + if (dataPt.labels[0] !== _t("Undefined")) { + dataPoints.push(dataPt); + } + counts += dataPt.count; + }); + // data points with zero count might have been created on purpose + // we only remove them if there are no data point with positive count + if (counts === 0) { + dataPoints = []; + } + } + return dataPoints; }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - /** - * Used to format correctly the value in tooltips and y axis + * Used to avoid too long legend items + * + * @private + * @param {string} label + * @returns {string} shortened version of the input label + */ + _shortenLabel: function (label) { + // string returned could be 'wrong' if a groupby value contain a '/'! + var groups = label.split("/"); + var shortLabel = groups.slice(0,3).join("/"); + if (groups.length > 3) { + shortLabel = shortLabel + '/...'; + } + return shortLabel; + }, + /** + * Used to format correctly the values in tooltips and yAxes * * @private * @param {number} value - * @returns {strin} The number formatted + * @returns {string} The value formatted using fieldUtils.format.float */ _formatValue: function (value) { - return field_utils.format.float(value, this.formatOption); + var measureField = this.fields[this.state.measure]; + var formatter = fieldUtils.format.float; + var formatedValue = formatter(value, measureField, FORMAT_OPTIONS); + return formatedValue; + }, + /** + * Used any time we need a new color in our charts. + * + * @private + * @param {number} index + * @returns {string} a color in HEX format + */ + _getColor: function (index) { + return COLORS[index % COLOR_NB]; + }, + /** + * Determines the initial section of the labels array + * over a dataset has to be completed. The section only depends + * on the datasets origins. + * + * @private + * @param {number} originIndex + * @param {number} defaultLength + * @returns {number} + */ + _getDatasetDataLength: function (originIndex, defaultLength) { + if (_.contains(['bar', 'line'], this.state.mode) && this.state.comparisonFieldIndex === 0) { + return this.dateClasses.dateSets[originIndex].length; + } + return defaultLength; + }, + /** + * Determines to which dataset belong the data point + * + * @private + * @param {Object} dataPt + * @returns {string} + */ + _getDatasetLabel:function (dataPt) { + if (_.contains(['bar', 'line'], this.state.mode)) { + // ([origin] + second to last groupBys) or measure + var datasetLabel = dataPt.labels.slice(1).join("/"); + if (this.state.origins.length > 1) { + datasetLabel = this.state.origins[dataPt.originIndex] + + (datasetLabel ? ('/' + datasetLabel) : ''); + } + datasetLabel = datasetLabel || this.fields[this.state.measure].string; + return datasetLabel; + } + return this.state.origins[dataPt.originIndex]; + }, + /** + * Returns a DateClasses instance used to manage equivalence of dates. + * + * @private + * @param {Object[]} dataPoints + * @returns {DateClasses} + */ + _getDateClasses: function (dataPoints) { + var self = this; + var dateSets = this.state.origins.map(function () { + return []; + }); + dataPoints.forEach(function (dataPt) { + dateSets[dataPt.originIndex].push(dataPt.labels[self.state.comparisonFieldIndex]); + }); + dateSets = dateSets.map(function (dateSet) { + return _.uniq(dateSet); + }); + return new DateClasses(dateSets); + }, + /** + * Determines over which label is the data point + * + * @private + * @param {Object} dataPt + * @returns {Array} + */ + _getLabel: function (dataPt) { + var i = this.state.comparisonFieldIndex; + if (_.contains(['bar', 'line'], this.state.mode)) { + if (i === 0) { + return [this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i])]; + } else { + return dataPt.labels.slice(0, 1); + } + } else if (i >= 0) { + return Array.prototype.concat.apply([], [ + dataPt.labels.slice(0, i), + this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i]), + dataPt.labels.slice(i+1) + ]); + } else { + return dataPt.labels; + } + }, + /** + * Returns the options used to generate the chart legend. + * + * @private + * @param {number} datasetsCount + * @returns {Object} + */ + _getLegendOptions: function (datasetsCount) { + var legendOptions = { + display: datasetsCount <= MAX_LEGEND_LENGTH, + position: config.device.size_class > config.device.SIZES.VSM ? 'right' : 'top', + }; + var self = this; + if (_.contains(['bar', 'line'], this.state.mode)) { + var referenceColor; + if (this.state.mode === 'bar') { + referenceColor = 'backgroundColor'; + } else { + referenceColor = 'borderColor'; + } + legendOptions.labels = { + generateLabels: function(chart) { + var data = chart.data; + return data.datasets.map(function(dataset, i) { + return { + text: self._shortenLabel(dataset.label), + fillStyle: dataset[referenceColor], + hidden: !chart.isDatasetVisible(i), + lineCap: dataset.borderCapStyle, + lineDash: dataset.borderDash, + lineDashOffset: dataset.borderDashOffset, + lineJoin: dataset.borderJoinStyle, + lineWidth: dataset.borderWidth, + strokeStyle: dataset[referenceColor], + pointStyle: dataset.pointStyle, + datasetIndex: i, + }; + }); + }, + }; + } else { + legendOptions.labels = { + generateLabels: function(chart) { + var data = chart.data; + var metaData = data.datasets.map(function (dataset, index) { + return chart.getDatasetMeta(index).data; + }); + return data.labels.map(function(label, i) { + var hidden = metaData.reduce( + function (hidden, data) { + if (data[i]) { + hidden = hidden || data[i].hidden; + } + return hidden; + }, + false + ); + var text = self._shortenLabel(self._relabelling(label)); + return { + text: text, + fillStyle: label.isNoData ? '#d3d3d3' : self._getColor(i), + hidden: hidden, + index: i, + }; + }); + }, + }; + } + return legendOptions; + }, + /** + * Returns the options used to generate the chart axes. + * + * @private + * @returns {Object} + */ + _getScaleOptions: function () { + var self = this; + if (_.contains(['bar', 'line'], this.state.mode)) { + return { + xAxes: [{ + type: 'category', + scaleLabel: { + display: this.state.groupBy.length && !this.isEmbedded, + labelString: this.state.groupBy.length ? + this.fields[this.state.groupBy[0].split(':')[0]].string : '', + }, + ticks: { + // don't use bind: callback is called with 'index' as second parameter + // with value labels.indexOf(label)! + callback: function (label) { + return self._relabelling(label); + }, + }, + }], + yAxes: [{ + type: 'linear', + scaleLabel: { + display: !this.isEmbedded, + labelString: this.fields[this.state.measure].string, + }, + stacked: this.state.mode === 'bar' && this.state.stacked, + ticks: { + callback: this._formatValue.bind(this), + suggestedMin: 0, + } + }], + }; + } + return {}; + }, + /** + * Returns the options used to generate chart tooltips. + * + * @private + * @param {number} datasetsCount + * @returns {Object} + */ + _getTooltipOptions: function (datasetsCount) { + var self = this; + var tooltipOptions = { + bodyFontColor: 'rgba(0,0,0,1)', + titleFontSize: 13, + titleFontColor: 'rgba(0,0,0,1)', + backgroundColor: 'rgba(255,255,255,0.6)', + borderColor: 'rgba(0,0,0,0.2)', + borderWidth: 1, + callbacks: { + title: function () { + return self.fields[self.state.measure].string; + }, + }, + }; + if (_.contains(['bar', 'line'], this.state.mode)) { + var referenceColor; + if (this.state.mode === 'bar') { + referenceColor = 'backgroundColor'; + } else { + referenceColor = 'borderColor'; + // avoid too long tooltips + var adaptMode = datasetsCount > MAX_TOOLTIPS_LENGTH || this.isEmbedded; + tooltipOptions = _.extend(tooltipOptions, { + mode: adaptMode ? 'nearest' : 'index', + intersect: false, + toolitemSort: function (tooltipItem1, tooltipItem2) { + return tooltipItem2.yLabel - tooltipItem1.yLabel; + }, + }); + } + tooltipOptions.callbacks = _.extend(tooltipOptions.callbacks, { + label: function (tooltipItem, data) { + var dataset = data.datasets[tooltipItem.datasetIndex]; + var label = data.labels[tooltipItem.index]; + label = self._relabelling(label, dataset.originIndex); + if (self.state.groupBy.length > 1 || self.state.origins.length > 1) { + label = label + "/" + dataset.label; + } + label = label + ': ' + self._formatValue(tooltipItem.yLabel); + return label; + }, + labelColor: function (tooltipItem, chart) { + var dataset = chart.data.datasets[tooltipItem.datasetIndex]; + var tooltipBackgroundColor = dataset[referenceColor]; + var tooltipBorderColor = chart.tooltip._model.backgroundColor; + return { + borderColor: tooltipBorderColor, + backgroundColor: tooltipBackgroundColor, + }; + }, + }); + } else { + tooltipOptions.callbacks = _.extend(tooltipOptions.callbacks, { + label: function (tooltipItem, data) { + var dataset = data.datasets[tooltipItem.datasetIndex]; + var label = data.labels[tooltipItem.index]; + if (label === _t('No data')) { + return dataset.label + "/" + label + ': ' + self._formatValue(0); + } else { + label = self._relabelling(label, dataset.originIndex); + } + if (self.state.origins.length > 1) { + label = dataset.label + "/" + label; + } + label = label + ': ' + self._formatValue(dataset.data[tooltipItem.index]); + return label; + }, + labelColor: function (tooltipItem, chart) { + var dataset = chart.data.datasets[tooltipItem.datasetIndex]; + var tooltipBackgroundColor = dataset.backgroundColor[tooltipItem.index]; + var tooltipBorderColor = chart.tooltip._model.backgroundColor; + return { + borderColor: tooltipBorderColor, + backgroundColor: tooltipBackgroundColor, + }; + }, + }); + } + return tooltipOptions; + }, + /** + * Return the first index of the array list where label can be found + * or -1. + * + * @private + * @param {Array[]} list + * @param {Array} label + * @returns {number} + */ + _indexOf: function (list, label) { + var index = -1; + for (var j = 0; j < list.length; j++) { + var otherLabel = list[j]; + if (label.length === otherLabel.length) { + var equal = true; + for (var i = 0; i < label.length; i++) { + if (label[i] !== otherLabel[i]) { + equal = false; + } + } + if (equal) { + index = j; + break; + } + } + } + return index; + }, + /** + * Separate dataPoints comming from the read_group(s) into different datasets. + * This function returns the parameters data and labels used to produce the charts. + * + * @private + * @param {Object[]} dataPoints + * @param {function} getLabel, + * @param {function} getDatasetLabel, determines to which dataset belong a given data point + * @param {function} [getDatasetDataLength], determines the initial section of the labels array + * over which the datasets have to be completed. These sections only depend + * on the datasets origins. Default is the constant function _ => labels.length. + * @returns {Object} the paramater data used to instatiate the chart. + */ + _prepareData: function (dataPoints) { + var self = this; + + var labels = dataPoints.reduce( + function (acc, dataPt) { + var label = self._getLabel(dataPt); + var index = self._indexOf(acc, label); + if (index === -1) { + acc.push(label); + } + return acc; + }, + [] + ); + + var newDataset = function (datasetLabel, originIndex) { + var data = new Array(self._getDatasetDataLength(originIndex, labels.length)).fill(0); + return { + label: datasetLabel, + data: data, + originIndex: originIndex, + }; + }; + + // dataPoints --> datasets + var datasets = _.values(dataPoints.reduce( + function (acc, dataPt) { + var datasetLabel = self._getDatasetLabel(dataPt); + if (!(datasetLabel in acc)) { + acc[datasetLabel] = newDataset(datasetLabel, dataPt.originIndex); + } + var label = self._getLabel(dataPt); + var labelIndex = self._indexOf(labels, label); + acc[datasetLabel].data[labelIndex] = dataPt.value; + return acc; + }, + {} + )); + + // sort by origin + datasets = datasets.sort(function (dataset1, dataset2) { + return dataset1.originIndex - dataset2.originIndex; + }); + + return { + datasets: datasets, + labels: labels, + }; }, /** - * Render the chart. + * Prepare options for the chart according to the current mode (= chart type). + * This function returns the parameter options used to instantiate the chart + * + * @private + * @param {number} datasetsCount + * @returns {Object} the chart options used for the current mode + */ + _prepareOptions: function (datasetsCount) { + return { + maintainAspectRatio: false, + scales: this._getScaleOptions(), + legend: this._getLegendOptions(datasetsCount), + tooltips: this._getTooltipOptions(datasetsCount), + }; + }, + /** + * Determine how to relabel a label according to a given origin. + * The idea is that the getLabel function is in general not invertible but + * it is when restricted to the set of dataPoints comming from a same origin. + + * @private + * @param {Array} label + * @param {Array} originIndex + * @returns {string} + */ + _relabelling: function (label, originIndex) { + if (label.isNoData) { + return label[0]; + } + var i = this.state.comparisonFieldIndex; + if (_.contains(['bar', 'line'], this.state.mode) && i === 0) { + // here label is an array of length 1 and contains a number + return this.dateClasses.representative(label, originIndex) || ''; + } else if (this.state.mode === 'pie' && i >= 0) { + // here label is an array of length at least one containing string or numbers + var labelCopy = label.slice(0); + if (originIndex !== undefined) { + labelCopy.splice(i, 1, this.dateClasses.representative(label[i], originIndex)); + } else { + labelCopy.splice(i, 1, this.dateClasses.dateClassMembers(label[i])); + } + return labelCopy.join('/'); + } + // here label is an array containing strings or numbers. + return label.join('/') || _t('Total'); + }, + /** + * Render the chart or display a message error in case data is not good enough. * * Note that This method is synchronous, but the actual rendering is done - * asynchronously. The reason for that is that nvd3/d3 needs to be in the + * asynchronously. The reason for that is that Chart.js needs to be in the * DOM to correctly render itself. So, we trick Odoo by returning * immediately, then we render the chart when the widget is in the DOM. * @@ -127,17 +609,18 @@ return AbstractRenderer.extend({ * @returns {Promise} The _super promise is actually resolved immediately */ _render: function () { - if (this.to_remove) { - nv.utils.offWindowResize(this.to_remove); + if (this.chart) { + this.chart.destroy(); } + this.$el.empty(); if (!_.contains(CHART_TYPES, this.state.mode)) { - this.$el.empty(); this.trigger_up('warning', { title: _t('Invalid mode for chart'), message: _t('Cannot render chart with mode : ') + this.state.mode }); - } else if (!this.state.dataPoints.length && this.state.mode !== 'pie') { - this.$el.empty(); + } + var dataPoints = this._filterDataPoints(); + if (!dataPoints.length && this.state.mode !== 'pie') { this.$el.append(qweb.render('GraphView.error', { title: _t("No data to display"), description: _t("Try to add some records, or make sure that " + @@ -148,238 +631,133 @@ return AbstractRenderer.extend({ // happens typically after an update), otherwise, it will be // rendered when the widget will be attached to the DOM (see // 'on_attach_callback') - this._renderGraph(); + var $canvasContainer = $('<div/>', {class: 'o_graph_canvas_container'}); + var $canvas = $('<canvas/>').attr('id', this.chartId); + $canvasContainer.append($canvas); + this.$el.append($canvasContainer); + + var i = this.state.comparisonFieldIndex; + if (i === 0 || (i > 0 && this.state.mode === 'pie')) { + this.dateClasses = this._getDateClasses(dataPoints); + } + if (this.state.mode === 'bar') { + this._renderBarChart(dataPoints); + } else if (this.state.mode === 'line') { + this._renderLineChart(dataPoints); + } else if (this.state.mode === 'pie') { + this._renderPieChart(dataPoints); + } + + this._renderTitle(); } return this._super.apply(this, arguments); }, - - _prepareDataSets: function (dataPoints) { + /** + * create bar chart. + * + * @private + * @param {Object[]} dataPoints + */ + _renderBarChart: function (dataPoints) { var self = this; - var measure = this.fields[this.state.measure].string; - - function getGroupLabel(dataPt) { - // ([origin] + second to last groupBys) or measure - var z = dataPt.labels.slice(1).join("/"); - if (self.state.compare) { - z = self.state.origins[dataPt.originIndex] + (z ? ('/' + z) : ''); - } - z = z || measure; - return z; - } - - function getXLabel(dataPt) { - return dataPt.labels.slice(0, 1).join("/") || _t('Total'); - } - var comparisonField = this.state.comparisonField; - var groupBys = this.state.groupBy.map(function (gb) { - return gb.split(":")[0]; + // style rectangles + Chart.defaults.global.elements.rectangle.borderWidth = 1; + + // prepare data + var data = this._prepareData(dataPoints); + data.datasets.forEach(function (dataset, index) { + // used when stacked + dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined; + // set dataset color + var color = self._getColor(index); + dataset.backgroundColor = color; }); - // if dateIdentification is true, we will identify dates coming from - // different origins if they have same rank in the corresponding date sets - // (date sets are assumed pairwise disjoint, naturally ordered and not all empty) - var dateIdentification = groupBys.indexOf(comparisonField) === 0; + // prepare options + var options = this._prepareOptions(data.datasets.length); - var dateSets; - var dateClasses; - if (dateIdentification) { - dateSets = this.state.origins.map(function () { - return []; - }); - dataPoints.forEach(function (dataPt) { - dateSets[dataPt.originIndex].push(dataPt.labels[0]); - }); - dateSets = dateSets.map(function (dateSet) { - return _.uniq(dateSet); - }); - var interval = this.state.groupBy[0].split(":")[1] || 'month'; - dateClasses = new DateClasses(dateSets, interval); - } - - // dataPoints --> points separated into different groups - var groups = {}; - var groupInfo = {}; - var xRange = []; - dataPoints.forEach(function (dataPt) { - var groupLabel = getGroupLabel(dataPt); - if (!(groupLabel in groups)) { - groups[groupLabel] = {}; - } - if (!(groupLabel in groupInfo)) { - groupInfo[groupLabel] = { - // by construction, all data points in the same group - // have the same origin. - originIndex: dataPt.originIndex, - xLabels: {} - }; - } - var xLabel = getXLabel(dataPt); - var xValue; - if (dateIdentification) { - xValue = dateClasses.representative(xLabel); - } else { - xValue = xLabel; - } - xRange.push(xValue); - - groups[groupLabel][xValue] = dataPt.value; - groupInfo[groupLabel].xLabels[xValue] = xLabel; - }); - - xRange = _.uniq(xRange); - - // If not dateIdentification, each group is completed to have points over each x in xrange - // If dateIdentification, we complete each group to have points over each x corresponding - // to a date coming from a datapoint from the same origin. - // Each group is transformed into a single datum for nvd3. - var datasets = Object.keys(groups).map(function (groupLabel) { - var range; - var originIndex = groupInfo[groupLabel].originIndex; - if (dateIdentification) { - range = xRange.slice(0, dateSets[originIndex].length); - } else { - range = xRange; - } - var points = range.reduce( - function (acc, xValue) { - var y = groups[groupLabel][xValue]; - if (!y) { - if (dateIdentification) { - groupInfo[groupLabel].xLabels[xValue] = dateClasses.representative(xValue, originIndex); - } else { - groupInfo[groupLabel].xLabels[xValue] = xValue; - } - } - acc.push({ - x: xValue, - y: y || 0, - }); - return acc; - }, - [] - ); - return { - key: groupLabel, - values: points - }; + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'bar', + data: data, + options: options, }); - - return { - datasets: datasets, - ticksLabels: xRange, - groupInfo: groupInfo, - }; }, - /** - * Helper function to set up data properly for the multiBarChart model in - * nvd3. + * create line chart. * - * @returns {nvd3 chart} + * @private + * @param {Object[]} dataPoints */ - _renderBarChart: function () { + _renderLineChart: function (dataPoints) { var self = this; - // prepare data for bar chart - var dataPoints = this.state.dataPoints.filter(function (dataPt) { - return dataPt.count > 0; - }); - - // put data in a format for nvd3 - var dataProcessed = this._prepareDataSets(dataPoints); - var groupInfo = dataProcessed.groupInfo; - this.datasets = dataProcessed.datasets; - - // style data - if (this.state.groupBy.length === 1) { - this.datasets.forEach(function (group) { - if (groupInfo[group.key].originIndex === 1) { - group.color = '#ff7f0e'; + // style lines + Chart.defaults.global.elements.line.tension = 0; + Chart.defaults.global.elements.line.fill = false; + + // prepare data + var data = this._prepareData(dataPoints); + data.datasets.forEach(function (dataset, index) { + if (self.state.groupBy.length <= 1 && self.state.origins.length > 1) { + if (dataset.originIndex === 0) { + dataset.fill = 'origin'; + dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4); + dataset.borderColor = hexToRGBA(COLORS[0], 1); + } else if (dataset.originIndex === 1) { + dataset.borderColor = hexToRGBA(COLORS[1], 1); + } else { + dataset.borderColor = self._getColor(index); } - }); - } - - // nvd3 specific - var $svgContainer = $('<div/>', {class: 'o_graph_svg_container'}); - this.$el.append($svgContainer); - var svg = d3.select($svgContainer[0]).append('svg'); - svg.datum(this.datasets); - - svg.transition().duration(0); - - var chart = nv.models.multiBarChart(); - chart.options({ - margin: {left: 80, bottom: 100, top: 80, right: 0}, - delay: 100, - transition: 10, - controlLabels: { - 'grouped': _t('Grouped'), - 'stacked': _t('Stacked'), - }, - showLegend: _.size(this.datasets) <= MAX_LEGEND_LENGTH, - showXAxis: true, - showYAxis: true, - rightAlignYAxis: false, - stacked: this.stacked, - reduceXTicks: false, - rotateLabels: -20, - showControls: (this.state.groupBy.length > 1) + } else { + dataset.borderColor = self._getColor(index); + } + if (data.labels.length === 1) { + // decalage of the real value to right. This is done to center the points in the chart + // See data.labels below in Chart parameters + dataset.data.unshift(undefined); + } + dataset.pointBackgroundColor = dataset.borderColor; + dataset.pointBorderColor = 'rgba(0,0,0,0.2)'; }); - - chart.yAxis - .tickFormat(function (d) { - return self._formatValue(d); - }); - - - chart.tooltip.contentGenerator(function (data) { - var lines = data.series.map(function (serie) { - var label = groupInfo[serie.key].xLabels[data.value]; - if (self.state.groupBy.length > 1 || self.state.compare) { - label = label + "/" + serie.key; - } - return { - color: serie.color, - label: label, - value: self._formatValue(serie.value), - }; - }); - return qweb.render("web.Chart.Tooltip", { - title: self.fields[self.state.measure].string, - lines: lines.sort(function (line1, line2) { - return line2.value - line1.value; - }), - }); + // center the points in the chart (whithout that code they are put on the left and the graph seems empty) + data.labels = data.labels.length > 1 ? + data.labels : + Array.prototype.concat.apply([], [[['']], data.labels ,[['']]]); + + // prepare options + var options = this._prepareOptions(data.datasets.length); + + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'line', + data: data, + options: options, }); - - chart(svg); - return chart; }, /** - * Helper function to set up data properly for the pieChart model in - * nvd3. + * create pie chart * - * returns undefined in the case of an non-embedded pie chart with no data. - * (all zero data included) - *. - * @returns {nvd3 chart|undefined} + * @private + * @param {Object[]} dataPoints */ - _renderPieChart: function (originIndex) { + _renderPieChart: function (dataPoints) { var self = this; - var all_negative = true; - var some_negative = false; - var all_zero = true; - var dataPoints = this.state.dataPoints.filter(function (dataPt) { - return dataPt.originIndex === originIndex && dataPt.count > 0; - }); + // try to see if some pathologies are still present after the filtering + var allNegative = true; + var someNegative = false; + var allZero = true; dataPoints.forEach(function (datapt) { - all_negative = all_negative && (datapt.value < 0); - some_negative = some_negative || (datapt.value < 0); - all_zero = all_zero && (datapt.value === 0); + allNegative = allNegative && (datapt.value < 0); + someNegative = someNegative || (datapt.value < 0); + allZero = allZero && (datapt.value === 0); }); - if (some_negative && !all_negative) { + if (someNegative && !allNegative) { + this.$el.empty(); this.$el.append(qweb.render('GraphView.error', { title: _t("Invalid data"), description: _t("Pie chart cannot mix positive and negative numbers. " + @@ -387,235 +765,83 @@ return AbstractRenderer.extend({ })); return; } - if (all_zero) { - if (this.isEmbedded || this.state.compare) { - // add fake data to display an empty pie chart - this.datasets = [{ - x : "No data" , - y : 1 - }]; - } else { - this.$el.append(qweb.render('GraphView.error', { - title: _t("Invalid data"), - description: _t("Pie chart cannot display all zero numbers.. " + - "Try to change your domain to display positive results"), - })); - return; - } - } else { - this.datasets = dataPoints.map(function (datapt) { - return { - x: datapt.labels.join("/") || _t('Total'), - y: datapt.value, - }; - }); - } - - var $svgContainer = $('<div/>', {class: 'o_graph_svg_container'}); - this.$el.append($svgContainer); - var svg = d3.select($svgContainer[0]).append('svg'); - svg.datum(this.datasets); - - svg.transition().duration(100); - - var color; - var legend_right = config.device.size_class > config.device.SIZES.VSM; - if (all_zero) { - color = (['lightgrey']); - svg.append("text") - .attr("text-anchor", "middle") - .attr("x", "50%") - .attr("y", "50%") - .text(_t("No data to display")); - } else { - color = d3.scale.category10().range(); + if (allZero && !this.isEmbedded && this.state.origins.length === 1) { + this.$el.empty(); + this.$el.append(qweb.render('GraphView.error', { + title: _t("Invalid data"), + description: _t("Pie chart cannot display all zero numbers.. " + + "Try to change your domain to display positive results"), + })); + return; } - var chart = nv.models.pieChart().labelType('percent'); - chart.options({ - delay: 250, - showLegend: !all_zero && (legend_right || _.size(this.datasets) <= MAX_LEGEND_LENGTH), - legendPosition: legend_right ? 'right' : 'top', - transition: 100, - color: color, - showLabels: all_zero ? false: true, - }); - - chart.tooltip.contentGenerator(function (data) { - return qweb.render("web.Chart.Tooltip", { - title: self.fields[self.state.measure].string, - lines : [{ - color: data.color, - label: data.data.x, - value: self._formatValue(data.data.y), - }], - }); - }); - - chart(svg); - return chart; - }, - /** - * Helper function to set up data properly for the line model in - * nvd3. - * - * @returns {nvd3 chart} - */ - _renderLineChart: function () { - var self = this; - - // remove some data points - var dataPoints = this.state.dataPoints.filter(function (dataPt) { - return dataPt.labels[0] !== _t("Undefined"); - }); - - var dataProcessed = this._prepareDataSets(dataPoints); - var ticksLabels = dataProcessed.ticksLabels; - var groupInfo = dataProcessed.groupInfo; - this.datasets = dataProcessed.datasets; - - // style data - if (this.state.groupBy.length === 1) { - this.datasets.forEach(function (group) { - if (groupInfo[group.key].originIndex === 0) { - group.area = true; - } - if (groupInfo[group.key].originIndex === 1) { - group.color = '#ff7f0e'; - } - }); - } - this.datasets.forEach(function (group) { - group.values = group.values.map(function (value) { + // prepare data + var data = {}; + var colors = []; + if (allZero) { + // add fake data to display a pie chart with a grey zone associated + // with every origin + data.labels = [NO_DATA]; + data.datasets = this.state.origins.map(function (origin) { return { - x: ticksLabels.indexOf(value.x), - y: value.y + label: origin, + data: [1], + backgroundColor: ['#d3d3d3'], }; }); - }); - - var $svgContainer = $('<div/>', { class: 'o_graph_svg_container'}); - // Split the tooltip into columns for large data because some portion goes out off the screen. - if (this.datasets.length >= SPLIT_THRESHOLD) { - $svgContainer.addClass('o_tooltip_split_in_columns'); - } - this.$el.append($svgContainer); - var svg = d3.select($svgContainer[0]).append('svg'); - svg.datum(this.datasets); - - svg.transition().duration(0); - - var chart = nv.models.lineChart(); - chart.options({ - margin: {left: 0, bottom: 20, top: 0, right: 0}, - useInteractiveGuideline: true, - showLegend: _.size(this.datasets) <= MAX_LEGEND_LENGTH, - showXAxis: true, - showYAxis: true, - stacked: true, - }); - chart.forceY([0]); - chart.xAxis - .tickFormat(function (d) { - return ticksLabels[d]; + } else { + data = this._prepareData(dataPoints); + // give same color to same groups from different origins + colors = data.labels.map(function (label, index) { + return self._getColor(index); }); - chart.yAxis - .showMaxMin(false) - .tickFormat(function (d) { - return self._formatValue(d); + data.datasets.forEach(function (dataset) { + dataset.backgroundColor = colors; + dataset.borderColor = 'rgba(255,255,255,0.6)'; }); - chart.yAxis.tickPadding(5); - chart.yAxis.orient("right"); - - chart.interactiveLayer.tooltip.contentGenerator(function (data) { - var lines = data.series.filter(function (serie) { - return serie.data.x === data.value; - }).map(function (serie) { - var label = groupInfo[serie.key].xLabels[ticksLabels[data.value]]; - if (self.state.groupBy.length > 1 || self.state.compare) { - label = label + "/" + serie.key; - } - return { - color: serie.color, - label: label, - value: self._formatValue(serie.value), - }; + // make sure there is a zone associated with every origin + var representedOriginIndexes = data.datasets.map(function (dataset) { + return dataset.originIndex; }); - return qweb.render("web.Chart.Tooltip", { - title: self.fields[self.state.measure].string, - lines: lines.sort(function (line1, line2) { - return line2.value - line1.value; - }), + var addNoDataToLegend = false; + var fakeData = (new Array(data.labels.length)).concat([1]); + this.state.origins.forEach(function (origin, originIndex) { + if (!_.contains(representedOriginIndexes, originIndex)) { + data.datasets.splice(originIndex, 0, { + label: origin, + data: fakeData, + backgroundColor: colors.concat(['#d3d3d3']), + }); + addNoDataToLegend = true; + } }); - }); - - chart(svg); - - // Bigger line (stroke-width 1.5 is hardcoded in nv.d3) - $svgContainer.find('.nvd3 .nv-groups g.nv-group').css('stroke-width', '2px'); - - // Delete first and last label because there is no enough space because - // of the tiny margins. - if (ticksLabels.length > 3) { - $svgContainer.find('svg .nv-x g.nv-axisMaxMin-x > text').hide(); + if (addNoDataToLegend) { + data.labels.push(NO_DATA); + } } - return chart; + // prepare options + var options = this._prepareOptions(data.datasets.length); + + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'pie', + data: data, + options: options, + }); }, /** - * Renders the graph according to its type. This function must be called - * when the renderer is in the DOM (for nvd3 to render the graph correctly). + * Add the graph title (if any) above the canvas * * @private */ - _renderGraph: function () { - var self = this; - - this.$el.empty(); - - function chartResize (chart){ - if (chart && chart.tooltip.chartContainer) { - self.to_remove = chart.update; - nv.utils.onWindowResize(chart.update); - chart.tooltip.chartContainer(self.$('.o_graph_svg_container').last()[0]); - } - } - var chart = this['_render' + _.str.capitalize(this.state.mode) + 'Chart'](0); - - if (chart) { - chart.dispatch.on('renderEnd', function () { - // FIXME: When 'orient' is right for Y axis, horizontal lines aren't displayed correctly - $('.nv-y .tick > line').attr('x2', function (i, value) { - return Math.abs(value); - }); - }); - chartResize(chart); - } - - if (this.state.mode === 'pie' && this.state.compare) { - // Render graph title - var chartTitle = this.title + ' (' + this.state.timeRangeDescription + ')'; - this.$('.o_graph_svg_container').last().prepend($('<label/>', { - text: chartTitle, - })); - - // Instantiate comparison graph - var comparisonChart = this['_render' + _.str.capitalize(this.state.mode) + 'Chart'](1); - // Render comparison graph title - var comparisonChartTitle = this.title + ' (' + this.state.comparisonTimeRangeDescription + ')'; - this.$('.o_graph_svg_container').last().prepend($('<label/>', { - text: comparisonChartTitle, - })); - chartResize(comparisonChart); - if (chart) { - chart.update(); - } - } else if (this.title) { - this.$('.o_graph_svg_container').last().prepend($('<label/>', { + _renderTitle: function () { + if (this.title) { + this.$('.o_graph_canvas_container').last().prepend($('<label/>', { text: this.title, })); } }, }); - }); 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 e731835bc30..f953c488a09 100644 --- a/addons/web/static/src/js/views/graph/graph_view.js +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -22,13 +22,8 @@ var GROUPABLE_TYPES = var GraphView = AbstractView.extend({ display_name: _lt('Graph'), icon: 'fa-bar-chart', - cssLibs: [ - '/web/static/lib/nvd3/nv.d3.css' - ], jsLibs: [ - '/web/static/lib/nvd3/d3.v3.js', - '/web/static/lib/nvd3/nv.d3.js', - '/web/static/src/js/libs/nvd3.js' + '/web/static/lib/Chart/Chart.js', ], config: { Model: GraphModel, @@ -80,7 +75,6 @@ var GraphView = AbstractView.extend({ this.controllerParams.measures = measures; this.controllerParams.groupableFields = groupableFields; this.rendererParams.fields = this.fields; - this.rendererParams.stacked = this.arch.attrs.stacked !== "False"; this.rendererParams.title = this.arch.attrs.title; // TODO: use attrs.string instead this.loadParams.mode = this.arch.attrs.type || 'bar'; @@ -88,6 +82,7 @@ var GraphView = AbstractView.extend({ this.loadParams.groupBys = groupBys; this.loadParams.fields = this.fields; this.loadParams.comparisonDomain = params.comparisonDomain; + this.loadParams.stacked = this.arch.attrs.stacked !== "False"; }, }); diff --git a/addons/web/static/src/scss/graph_view.scss b/addons/web/static/src/scss/graph_view.scss index e67edbd3cb5..fdf11967daa 100644 --- a/addons/web/static/src/scss/graph_view.scss +++ b/addons/web/static/src/scss/graph_view.scss @@ -2,62 +2,15 @@ .o_graph_renderer { height: 100%; overflow: auto; - .o_graph_svg_container { - direction: ltr#{"/*rtl:ignore*/"}; + .o_graph_canvas_container { + position: relative; height: 100%; - overflow: auto; - svg { + width: 100%; + top: 0px; + left: 0px; + canvas { background-color: $o-view-background-color; - .nv-y { - path.domain, g.tick.zero { - // Hide y axis line and zero tick label - display: none; - } - } - .nv-axis.nv-x path.domain { - stroke-opacity: 0.2; - } - .nv-groups g.nv-group { - stroke-width:10px; - } - .nv-groups path.nv-area { - // ligther area color - fill-opacity: 0.1; - } - .nv-lineChart .nv-x.nv-axis .nv-axisMaxMin-x { - &.nv-axisMin-x text { - text-anchor: start !important; - } - &.nv-axisMax-x text { - text-anchor: end !important; - } - } - } - - /* Use less than 50% of the screen area for tooltip and show legends in 3 column of 33% each */ - &.o_tooltip_split_in_columns .nvtooltip { - max-width: 48%; - tbody { - display: flex; - flex-flow: wrap; - tr { - width: 33%; - td { - display: inline-block; - &.key { - min-width: 160px; - max-width: 70%; - overflow-x: hidden; - text-overflow: ellipsis; - } - } - } - } } } } } - -.nvtooltip thead { - background-color: transparent; -} diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index a12f09ef4a0..75426a36d06 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -521,6 +521,9 @@ <button class="btn btn-secondary fa fa-area-chart o_graph_button" title="Line Chart" aria-label="Line Chart" data-mode="line"/> <button class="btn btn-secondary fa fa-pie-chart o_graph_button" title="Pie Chart" aria-label="Pie Chart" data-mode="pie"/> </div> + <div class="btn-group" role="toolbar" aria-label="Change graph"> + <button class="btn btn-secondary fa fa-database o_graph_button" title="Stacked" aria-label="Stacked" data-mode="stack"/> + </div> </t> <div t-name="GraphView.error" class="o_view_nocontent" role="alert"> <div class="o_nocontent_help"> diff --git a/addons/web/static/src/xml/chart.xml b/addons/web/static/src/xml/chart.xml index a6f5cccc712..d45bef7af6f 100644 --- a/addons/web/static/src/xml/chart.xml +++ b/addons/web/static/src/xml/chart.xml @@ -7,33 +7,4 @@ </graph> </t> -<t t-name="web.Chart.Tooltip"> - <table> - <thead> - <tr> - <td colspan="3"> - <strong class="x-value"> - <t t-esc="title"/> - </strong> - </td> - </tr> - </thead> - <tbody> - <t t-foreach="lines" t-as="line"> - <tr> - <td class="legend-color-guide"> - <div t-att-style="'background-color:' + line.color +';'"/> - </td> - <td class="key"> - <t t-esc="line.label"/> - </td> - <td class="value"> - <t t-esc="line.value && window.parseFloat(line.value).toFixed(2) || 0"/> - </td> - </tr> - </t> - </tbody> - </table> -</t> - </template> diff --git a/addons/web/static/tests/core/data_comparison_utils_tests.js b/addons/web/static/tests/core/data_comparison_utils_tests.js new file mode 100644 index 00000000000..f5058714d20 --- /dev/null +++ b/addons/web/static/tests/core/data_comparison_utils_tests.js @@ -0,0 +1,75 @@ +odoo.define('web.data_comparison_utils_tests', function(require) { +"use strict"; + +var dataComparisonUtils = require('web.dataComparisonUtils'); +var DateClasses = dataComparisonUtils.DateClasses; + +QUnit.module('dataComparisonUtils', function () { + + QUnit.module('DateClasses'); + + + QUnit.test('main parameters are correctly computed', function(assert) { + assert.expect(30); + + var dateClasses; + + dateClasses = new DateClasses([['2019']]); + assert.strictEqual(dateClasses.referenceIndex, 0); + assert.strictEqual(dateClasses.dateClass(0, '2019'), 0); + assert.deepEqual(dateClasses.dateClassMembers(0), ['2019']); + + dateClasses = new DateClasses([['2018', '2019']]); + assert.strictEqual(dateClasses.referenceIndex, 0); + assert.strictEqual(dateClasses.dateClass(0, '2018'), 0); + assert.strictEqual(dateClasses.dateClass(0, '2019'), 1); + assert.deepEqual(dateClasses.dateClassMembers(0), ['2018']); + assert.deepEqual(dateClasses.dateClassMembers(1), ['2019']); + + dateClasses = new DateClasses([['2019'], []]); + assert.strictEqual(dateClasses.referenceIndex, 0); + assert.strictEqual(dateClasses.dateClass(0, '2019'), 0); + assert.deepEqual(dateClasses.dateClassMembers(0), ['2019']); + + dateClasses = new DateClasses([[], ['2019']]); + assert.strictEqual(dateClasses.referenceIndex, 1); + assert.strictEqual(dateClasses.dateClass(1, '2019'), 0); + assert.deepEqual(dateClasses.dateClassMembers(0), ['2019']); + + dateClasses = new DateClasses([['2019'],['2018', '2019']]); + assert.strictEqual(dateClasses.referenceIndex, 0); + assert.strictEqual(dateClasses.dateClass(0, '2019'), 0); + assert.strictEqual(dateClasses.dateClass(1, '2018'), 0); + assert.strictEqual(dateClasses.dateClass(1, '2019'), 1); + assert.deepEqual(dateClasses.dateClassMembers(0), ['2019', '2018']); + assert.deepEqual(dateClasses.dateClassMembers(1), ['2019']); + + + dateClasses = new DateClasses([['2019'], ['2017', '2018', '2020'], ['2017', '2019']]); + assert.strictEqual(dateClasses.referenceIndex, 0); + assert.strictEqual(dateClasses.dateClass(0, '2019'), 0); + assert.strictEqual(dateClasses.dateClass(1, '2017'), 0); + assert.strictEqual(dateClasses.dateClass(1, '2018'), 1); + assert.strictEqual(dateClasses.dateClass(1, '2020'), 2); + assert.strictEqual(dateClasses.dateClass(2, '2017'), 0); + assert.strictEqual(dateClasses.dateClass(2, '2019'), 1); + assert.deepEqual(dateClasses.dateClassMembers(0), ['2019', '2017']); + assert.deepEqual(dateClasses.dateClassMembers(1), ['2018', '2019']); + assert.deepEqual(dateClasses.dateClassMembers(2), ['2020']); + + + }); + + QUnit.test('two overlapping datesets and classes representatives', function(assert) { + assert.expect(4); + + var dateClasses = new DateClasses([['March 2017'], ['February 2017', 'March 2017']]); + + assert.strictEqual(dateClasses.representative(0, 0), 'March 2017'); + assert.strictEqual(dateClasses.representative(0, 1), 'February 2017'); + + assert.strictEqual(dateClasses.representative(1, 0), undefined); + assert.strictEqual(dateClasses.representative(1, 1), 'March 2017'); + }); +}); +}); diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js index 7a0ff7d5846..ca969fb9ac9 100644 --- a/addons/web/static/tests/views/graph_tests.js +++ b/addons/web/static/tests/views/graph_tests.js @@ -1,11 +1,61 @@ -odoo.define('web.graph_tests', function (require) { +odoo.define('web.graph_view_tests', function (require) { "use strict"; var concurrency = require('web.concurrency'); +var controlPanelViewParameters = require('web.controlPanelViewParameters'); var GraphView = require('web.GraphView'); var testUtils = require('web.test_utils'); +var createActionManager = testUtils.createActionManager; var createView = testUtils.createView; +var patchDate = testUtils.mock.patchDate; + +var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS.map(o => o.optionId); +var TIME_RANGE_OPTIONS = controlPanelViewParameters.TIME_RANGE_OPTIONS.map(o => o.optionId); +var COMPARISON_TIME_RANGE_OPTIONS = controlPanelViewParameters.COMPARISON_TIME_RANGE_OPTIONS.map(o => o.optionId); + +var f = (a, b) => [].concat(...a.map(d => b.map(e => [].concat(d, e)))); +var cartesian = (a, b, ...c) => (b ? cartesian(f(a, b), ...c) : a); + +var COMBINATIONS = cartesian(TIME_RANGE_OPTIONS, COMPARISON_TIME_RANGE_OPTIONS); +var COMBINATIONS_WITH_DATE = cartesian(TIME_RANGE_OPTIONS, COMPARISON_TIME_RANGE_OPTIONS, INTERVAL_OPTIONS); + +QUnit.assert.checkDatasets = function (graph, keys, expectedDatasets) { + keys = keys instanceof Array ? keys : [keys]; + expectedDatasets = expectedDatasets instanceof Array ? + expectedDatasets : + [expectedDatasets]; + var datasets = graph.renderer.chart.data.datasets; + var actualValues = datasets.map(dataset => _.pick(dataset, keys)); + this.pushResult({ + result: _.isEqual(actualValues, expectedDatasets), + actual: actualValues, + expected: expectedDatasets, + }); +}; + +QUnit.assert.checkLabels = function (graph, expectedLabels) { + var labels = graph.renderer.chart.data.labels; + this.pushResult({ + result: _.isEqual(labels, expectedLabels), + actual: labels, + expected: expectedLabels, + }); +}; + +QUnit.assert.checkLegend = function (graph, expectedLegendLabels) { + expectedLegendLabels = expectedLegendLabels instanceof Array ? + expectedLegendLabels : + [expectedLegendLabels]; + var chart = graph.renderer.chart; + var actualLegendLabels = chart.config.options.legend.labels.generateLabels(chart).map(o => o.text); + + this.pushResult({ + result: _.isEqual(actualLegendLabels, expectedLegendLabels), + actual: actualLegendLabels, + expected: expectedLegendLabels, + }); +}; QUnit.module('Views', { beforeEach: function () { @@ -16,7 +66,7 @@ QUnit.module('Views', { bar: {string: "bar", type: "boolean"}, product_id: {string: "Product", type: "many2one", relation: 'product', store: true}, color_id: {string: "Color", type: "many2one", relation: 'color'}, - date: {string: "Date", type: 'date'}, + date: {string: "Date", type: 'date', store: true, sortable: true}, }, records: [ {id: 1, foo: 3, bar: true, product_id: 37, date: "2016-01-01"}, @@ -60,7 +110,7 @@ QUnit.module('Views', { QUnit.module('GraphView'); QUnit.test('simple graph rendering', async function (assert) { - assert.expect(4); + assert.expect(5); var graph = await createView({ View: GraphView, @@ -71,25 +121,24 @@ QUnit.module('Views', { '</graph>', }); - var done = assert.async(); - return concurrency.delay(0).then(function () { - assert.containsOnce(graph, 'div.o_graph_svg_container svg.nvd3-svg', - "should contain a div with a svg element"); + assert.containsOnce(graph, 'div.o_graph_canvas_container canvas', + "should contain a div with a canvas element"); + assert.strictEqual(graph.renderer.state.mode, "bar", + "should be in bar chart mode by default"); + assert.checkLabels(graph, [[true], [false]]); + assert.checkDatasets(graph, + ['backgroundColor', 'data', 'label', 'originIndex', 'stack'], + { + backgroundColor: "#1f77b4", + data: [3,5], + label: "Count", + originIndex: 0, + stack: "", + } + ); + assert.checkLegend(graph, 'Count'); - assert.strictEqual(graph.renderer.state.mode, "bar", - "should be in bar chart mode by default"); - - // here, we would like to test the svg in the dom. However, it is - // not so easy, because there is an animation which means that we - // don't really have a nice way to find the proper rect elements. - // So, instead we will do some white box testing. - assert.strictEqual(graph.model.chart.dataPoints[0].value, 3, - "should have first datapoint with value 3"); - assert.strictEqual(graph.model.chart.dataPoints[1].value, 5, - "should have second datapoint with value 5"); - graph.destroy(); - done(); - }); + graph.destroy(); }); QUnit.test('default type attribute', async function (assert) { @@ -103,7 +152,9 @@ QUnit.module('Views', { '<field name="bar"/>' + '</graph>', }); + assert.strictEqual(graph.renderer.state.mode, "pie", "should be in pie chart mode by default"); + graph.destroy(); }); @@ -118,8 +169,10 @@ QUnit.module('Views', { '<field name="bar"/>' + '</graph>', }); + assert.strictEqual(graph.$('.o_graph_renderer label').text(), "Partners", "should have 'Partners as title'"); + graph.destroy(); }); @@ -134,17 +187,20 @@ QUnit.module('Views', { '<field name="bar"/>' + '</graph>', }); + assert.strictEqual(graph.renderer.state.mode, "line", "should be in line chart mode by default"); assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="bar"]'), 'active', 'bar type button should not be active'); assert.hasClass(graph.$buttons.find('button[data-mode="line"]'),'active', 'line type button should be active'); + await testUtils.dom.click(graph.$buttons.find('button[data-mode="bar"]')); assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode by default"); assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="line"]'), 'active', 'line type button should not be active'); assert.hasClass(graph.$buttons.find('button[data-mode="bar"]'),'active', 'bar type button should be active'); + graph.destroy(); }); @@ -152,7 +208,6 @@ QUnit.module('Views', { assert.expect(1); // this test makes sure the line chart does not crash when only one data // point is displayed. - var done = assert.async(); this.data.foo.records = this.data.foo.records.slice(0,1); var graph = await createView({ View: GraphView, @@ -162,38 +217,16 @@ QUnit.module('Views', { '<field name="bar"/>' + '</graph>', }); - return concurrency.delay(0).then(function () { - assert.ok(graph.$('svg').length, "should have a svg"); - graph.destroy(); - done(); - }); - }); - - QUnit.test('displaying line chart data with multiple groupbys', async function (assert) { - // this test makes sure the line chart shows all data labels (X axis) when - // it is grouped by several fields - assert.expect(3); - - var graph = await createView({ - View: GraphView, - model: 'foo', - data: this.data, - arch: '<graph type="line"><field name="foo" /></graph>', - groupBy: ['product_id', 'bar', 'color_id'], - }); - assert.strictEqual(graph.$('.nv-x text:contains(xphone)').length, 1, - "should contain a text element with product xphone on X axis"); - assert.strictEqual(graph.$('.nv-x text:contains(xpad)').length, 1, - "should contain a text element with product xpad on X axis"); - assert.strictEqual(graph.$('text.nv-legend-text:contains(true)').length, 2, - "should have an entry for each value of field 'bar' and 'color_id' in the legend"); + assert.containsOnce(graph, 'canvas', "should have a canvas"); graph.destroy(); }); - QUnit.test('displaying bar chart data with multiple groupbys', async function (assert) { - assert.expect(3); + QUnit.test('displaying chart data with multiple groupbys', async function (assert) { + // this test makes sure the line chart shows all data labels (X axis) when + // it is grouped by several fields + assert.expect(6); var graph = await createView({ View: GraphView, @@ -203,19 +236,32 @@ QUnit.module('Views', { groupBy: ['product_id', 'bar', 'color_id'], }); - assert.containsOnce(graph, '.nv-x text:contains(xphone)', - "should contain a text element with product xphone on X axis"); - assert.containsOnce(graph, '.nv-x text:contains(xpad)', - "should contain a text element with product xpad on X axis"); - assert.containsOnce(graph, 'text.nv-legend-text:contains(true/red)', - "should have an entry for each value of field 'bar' and 'color_id' in the legend"); + assert.checkLabels(graph, [['xphone'], ['xpad']]); + assert.checkLegend(graph, ['true/Undefined', 'true/red', 'false/Undefined']); + + await testUtils.dom.click(graph.$buttons.find('button[data-mode="line"]')); + assert.checkLabels(graph, [['xphone'], ['xpad']]); + assert.checkLegend(graph, ['true/Undefined', 'true/red', 'false/Undefined']); + + await testUtils.dom.click(graph.$buttons.find('button[data-mode="pie"]')); + assert.checkLabels(graph, [ + ["xphone", true, "Undefined"], + ["xphone", true,"red"], + ["xphone", false, "Undefined"], + ["xpad", false, "Undefined"] + ]); + assert.checkLegend(graph, [ + 'xphone/true/Undefined', + 'xphone/true/red', + 'xphone/false/Undefined', + 'xpad/false/Undefined' + ]); graph.destroy(); }); QUnit.test('switching measures', async function (assert) { - var done = assert.async(); - assert.expect(4); + assert.expect(2); var rpcCount = 0; @@ -231,22 +277,13 @@ QUnit.module('Views', { return this._super(route, args); }, }); - return concurrency.delay(0).then(function () { - assert.ok(graph.$('text.nv-legend-text:contains(Count)').length, - "should have used the correct measure"); - assert.ok(graph.$buttons.find('.dropdown-item[data-field="foo"]').length, - "should have foo in the list of measures"); - testUtils.dom.click(graph.$buttons.find('.dropdown-toggle:contains(Measures)')); - testUtils.dom.click(graph.$buttons.find('.dropdown-item[data-field="foo"]')); - return concurrency.delay(0); - }).then(function () { - assert.ok(graph.$('text.nv-legend-text:contains(Foo)').length, - "should now use the Foo measure"); - assert.strictEqual(rpcCount, 2, "should have done 2 rpcs (2 readgroups)"); - graph.destroy(); - done(); - }); + testUtils.dom.click(graph.$buttons.find('.dropdown-toggle:contains(Measures)')); + await testUtils.dom.click(graph.$buttons.find('.dropdown-item[data-field="foo"]')); + assert.checkLegend(graph, 'Foo'); + assert.strictEqual(rpcCount, 2, "should have done 2 rpcs (2 readgroups)"); + + graph.destroy(); }); QUnit.test('no content helper (bar chart)', async function (assert) { @@ -261,10 +298,12 @@ QUnit.module('Views', { '<field name="product_id"/>' + '</graph>', }); - assert.containsNone(graph, 'div.o_graph_svg_container svg.nvd3-svg', - "should not contain a div with a svg element"); + + assert.containsNone(graph, 'div.o_graph_canvas_container canvas', + "should not contain a div with a canvas element"); assert.containsOnce(graph, 'div.o_view_nocontent', "should display the no content helper"); + graph.destroy(); }); @@ -280,10 +319,12 @@ QUnit.module('Views', { '<field name="product_id"/>' + '</graph>', }); - assert.containsNone(graph, 'div.o_graph_svg_container svg.nvd3-svg', - "should not contain a div with a svg element"); + + assert.containsNone(graph, 'div.o_graph_canvas_container canvas', + "should not contain a div with a canvas element"); assert.containsOnce(graph, 'div.o_view_nocontent', "should display the no content helper"); + graph.destroy(); }); @@ -311,8 +352,8 @@ QUnit.module('Views', { assert.containsNone(graph, 'div.o_view_nocontent', "should not display the no content helper"); - assert.strictEqual($('.o_graph_svg_container svg > text').text(), - "No data to displayNo data to display", "should display two empty pie charts instead"); + assert.checkLegend(graph, 'No data'); + graph.destroy(); }); @@ -328,23 +369,21 @@ QUnit.module('Views', { '</graph>', }); - await testUtils.nextTick(); - assert.ok(graph.$('div.o_graph_svg_container svg.nvd3-svg').length, - "should contain a div with a svg element"); - assert.notOk(graph.$('div.o_view_nocontent').length, + assert.containsOnce(graph, 'div.o_graph_canvas_container canvas', + "should contain a div with a canvas element"); + assert.containsNone(graph, 'div.o_view_nocontent', "should not display the no content helper"); - testUtils.graph.reload(graph, {domain: [['product_id', '=', 4]]}); - await testUtils.nextTick(); - assert.notOk(graph.$('div.o_graph_svg_container svg.nvd3-svg').length, - "should not contain a div with a svg element"); - assert.ok(graph.$('div.o_view_nocontent').length, + + await testUtils.graph.reload(graph, {domain: [['product_id', '=', 4]]}); + assert.containsNone(graph, 'div.o_graph_canvas_container canvas', + "should not contain a div with a canvas element"); + assert.containsOnce(graph, 'div.o_view_nocontent', "should display the no content helper"); graph.destroy(); }); QUnit.test('can reload with other group by', async function (assert) { - var done = assert.async(); - assert.expect(4); + assert.expect(2); var graph = await createView({ View: GraphView, @@ -354,27 +393,16 @@ QUnit.module('Views', { '<field name="product_id"/>' + '</graph>', }); - return concurrency.delay(0).then(function () { - assert.ok(graph.$('text:contains(xphone)').length, - "should contain a text element with product in legend"); - assert.notOk(graph.$('text:contains(red)').length, - "should not contain a text element with color in legend"); - testUtils.graph.reload(graph, {groupBy: ['color_id']}); + assert.checkLabels(graph, [['xphone'], ['xpad']]); - return concurrency.delay(0); - }).then(function () { - assert.notOk(graph.$('text:contains(xphone)').length, - "should not contain a text element with product in legend"); - assert.ok(graph.$('text:contains(red)').length, - "should contain a text element with color in legend"); - graph.destroy(); - done(); - }); + await testUtils.graph.reload(graph, {groupBy: ['color_id']}); + assert.checkLabels(graph, [['Undefined'], ['red']]); + + graph.destroy(); }); - QUnit.test('getOwnedQueryParams correctly returns mode, measure, groupbys and interval mapping', async function (assert) { - var done = assert.async(); + QUnit.test('getOwnedQueryParams correctly returns mode, measure, and groupbys', async function (assert) { assert.expect(4); var graph = await createView({ @@ -385,60 +413,48 @@ QUnit.module('Views', { '<field name="product_id"/>' + '</graph>', }); - return concurrency.delay(0).then(function () { - assert.deepEqual(graph.getOwnedQueryParams(), { - context: { - graph_mode: 'bar', - graph_measure: '__count__', - graph_groupbys: ['product_id'], - } - }, "context should be correct"); - - testUtils.dom.click(graph.$buttons.find('.dropdown-toggle:contains(Measures)')); - testUtils.dom.click(graph.$buttons.find('.dropdown-item[data-field="foo"]')); - return concurrency.delay(0); - }).then(function () { - assert.deepEqual(graph.getOwnedQueryParams(), { - context: { - graph_mode: 'bar', - graph_measure: 'foo', - graph_groupbys: ['product_id'], - }, - }, "context should be correct"); - - testUtils.dom.click(graph.$buttons.find('button[data-mode="line"]')); - - return concurrency.delay(0); - }).then(function () { - assert.deepEqual(graph.getOwnedQueryParams(), { - context: { - graph_mode: 'line', - graph_measure: 'foo', - graph_groupbys: ['product_id'], - }, - }, "context should be correct"); + assert.deepEqual(graph.getOwnedQueryParams(), { + context: { + graph_mode: 'bar', + graph_measure: '__count__', + graph_groupbys: ['product_id'], + } + }, "context should be correct"); + + testUtils.dom.click(graph.$buttons.find('.dropdown-toggle:contains(Measures)')); + await testUtils.dom.click(graph.$buttons.find('.dropdown-item[data-field="foo"]')); + assert.deepEqual(graph.getOwnedQueryParams(), { + context: { + graph_mode: 'bar', + graph_measure: 'foo', + graph_groupbys: ['product_id'], + }, + }, "context should be correct"); - testUtils.graph.reload(graph, {groupBy: ['product_id', 'color_id']}); // change groupbys + await testUtils.dom.click(graph.$buttons.find('button[data-mode="line"]')); + assert.deepEqual(graph.getOwnedQueryParams(), { + context: { + graph_mode: 'line', + graph_measure: 'foo', + graph_groupbys: ['product_id'], + }, + }, "context should be correct"); - return concurrency.delay(0); - }).then(function () { - assert.deepEqual(graph.getOwnedQueryParams(), { - context: { - graph_mode: 'line', - graph_measure: 'foo', - graph_groupbys: ['product_id', 'color_id'], - }, - }, "context should be correct"); + await testUtils.graph.reload(graph, {groupBy: ['product_id', 'color_id']}); // change groupbys + assert.deepEqual(graph.getOwnedQueryParams(), { + context: { + graph_mode: 'line', + graph_measure: 'foo', + graph_groupbys: ['product_id', 'color_id'], + }, + }, "context should be correct"); - graph.destroy(); - done(); - }); + graph.destroy(); }); QUnit.test('correctly uses graph_ keys from the context', async function (assert) { - var done = assert.async(); - assert.expect(6); + assert.expect(5); var lastOne = _.last(this.data.foo.records); lastOne.color_id = 14; @@ -456,32 +472,22 @@ QUnit.module('Views', { }, }, }); - return concurrency.delay(0).then(function () { - // check measure - assert.strictEqual(graph.$('text.nv-legend-text:contains(Foo)').length, 1, - "should now use the 'foo' measure"); - - // check mode - assert.strictEqual(graph.renderer.state.mode, "line", "should be in line chart mode"); - assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="bar"]'), 'active', - 'bar chart button should not be active'); - assert.hasClass(graph.$buttons.find('button[data-mode="line"]'),'active', - 'line chart button should be active'); - - // check groupbys - assert.strictEqual(graph.$('text:contains(xphone)').length, 0, - "should not contain a text element with product in legend"); - assert.strictEqual(graph.$('text:contains(red)').length, 1, - "should contain a text element with color in legend"); + // check measure name is present in legend + assert.checkLegend(graph, 'Foo'); + // check mode + assert.strictEqual(graph.renderer.state.mode, "line", "should be in line chart mode"); + assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="bar"]'), 'active', + 'bar chart button should not be active'); + assert.hasClass(graph.$buttons.find('button[data-mode="line"]'),'active', + 'line chart button should be active'); + // check groupby values ('Undefined' is rejected in line chart) are in labels + assert.checkLabels(graph, [['red'], ['black']]); - graph.destroy(); - done(); - }); + graph.destroy(); }); QUnit.test('correctly use group_by key from the context', async function (assert) { - var done = assert.async(); - assert.expect(2); + assert.expect(1); var lastOne = _.last(this.data.foo.records); lastOne.color_id = 14; @@ -499,19 +505,14 @@ QUnit.module('Views', { }, }, }); - return concurrency.delay(0).then(function () { - assert.strictEqual(graph.$('text:contains(xphone)').length, 0, - 'should not contain a text element with product in legend'); - assert.strictEqual(graph.$('text:contains(red)').length, 1, - 'should contain a text element with color in legend'); - graph.destroy(); - done(); - }); + // check groupby values ('Undefined' is rejected in line chart) are in labels + assert.checkLabels(graph, [['red'], ['black']]); + + graph.destroy(); }); QUnit.test('correctly uses graph_ keys from the context (at reload)', async function (assert) { - var done = assert.async(); - assert.expect(8); + assert.expect(7); var lastOne = _.last(this.data.foo.records); lastOne.color_id = 14; @@ -534,28 +535,20 @@ QUnit.module('Views', { graph_groupbys: ['color_id'], }, }; - testUtils.graph.reload(graph, reloadParams); - return concurrency.delay(0).then(function () { - // check measure - assert.strictEqual(graph.$('text.nv-legend-text:contains(Foo)').length, 1, - "should now use the 'foo' measure"); - - // check mode - assert.strictEqual(graph.renderer.state.mode, "line", "should be in line chart mode"); - assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="bar"]'), 'active', - 'bar chart button should not be active'); - assert.hasClass(graph.$buttons.find('button[data-mode="line"]'),'active', - 'line chart button should be active'); + await testUtils.graph.reload(graph, reloadParams); - // check groupbys - assert.strictEqual(graph.$('text:contains(xphone)').length, 0, - "should not contain a text element with product in legend"); - assert.strictEqual(graph.$('text:contains(red)').length, 1, - "should contain a text element with color in legend"); + // check measure + assert.checkLegend(graph, 'Foo'); + // check mode + assert.strictEqual(graph.renderer.state.mode, "line", "should be in line chart mode"); + assert.doesNotHaveClass(graph.$buttons.find('button[data-mode="bar"]'), 'active', + 'bar chart button should not be active'); + assert.hasClass(graph.$buttons.find('button[data-mode="line"]'),'active', + 'line chart button should be active'); + // check groupby values ('Undefined' is rejected in line chart) are in labels + assert.checkLabels(graph, [['red'], ['black']]) - graph.destroy(); - done(); - }); + graph.destroy(); }); QUnit.test('reload graph with correct fields', async function (assert) { @@ -584,8 +577,7 @@ QUnit.module('Views', { }); QUnit.test('initial groupby is kept when reloading', async function (assert) { - var done = assert.async(); - assert.expect(4); + assert.expect(8); var graph = await createView({ View: GraphView, @@ -604,24 +596,20 @@ QUnit.module('Views', { }, }); + assert.checkLabels(graph, [['xphone'], ['xpad']]); + assert.checkLegend(graph, 'Foo'); + assert.checkDatasets(graph, 'data', {data: [82, 157]}); - return concurrency.delay(0).then(function () { - assert.containsN(graph, '.nv-groups rect', 2, - "should display two groups"); - - testUtils.graph.reload(graph, {groupBy: []}); - return concurrency.delay(0).then(function () { - assert.containsN(graph, '.nv-groups rect', 2, - "should still display two groups"); + await testUtils.graph.reload(graph, {groupBy: []}); + assert.checkLabels(graph, [['xphone'], ['xpad']]); + assert.checkLegend(graph, 'Foo'); + assert.checkDatasets(graph, 'data', {data: [82, 157]}); - graph.destroy(); - done(); - }); - }); + graph.destroy(); }); QUnit.test('use a many2one as a measure should work (without groupBy)', async function (assert) { - assert.expect(3); + assert.expect(4); var graph = await createView({ View: GraphView, @@ -631,22 +619,17 @@ QUnit.module('Views', { '<field name="product_id" type="measure"/>' + '</graph>', }); - var done = assert.async(); - return concurrency.delay(0).then(function () { - assert.containsOnce(graph, 'div.o_graph_svg_container svg.nvd3-svg', - "should contain a div with a svg element"); + assert.containsOnce(graph, 'div.o_graph_canvas_container canvas', + "should contain a div with a canvas element"); + assert.checkLabels(graph, [[]]); + assert.checkLegend(graph, 'Product'); + assert.checkDatasets(graph, 'data', {data: [2]}); - assert.strictEqual(graph.renderer.state.mode, "bar", - "should be in bar chart mode by default"); - assert.strictEqual(graph.model.chart.dataPoints[0].value, 2, - "should have a datapoint with value 2"); - graph.destroy(); - done(); - }); + graph.destroy(); }); QUnit.test('use a many2one as a measure should work (with groupBy)', async function (assert) { - assert.expect(4); + assert.expect(5); var graph = await createView({ View: GraphView, @@ -657,24 +640,20 @@ QUnit.module('Views', { '<field name="product_id" type="measure"/>' + '</graph>', }); - var done = assert.async(); - return concurrency.delay(0).then(function () { - assert.containsOnce(graph, 'div.o_graph_svg_container svg.nvd3-svg', - "should contain a div with a svg element"); + assert.containsOnce(graph, 'div.o_graph_canvas_container canvas', + "should contain a div with a canvas element"); - assert.strictEqual(graph.renderer.state.mode, "bar", - "should be in bar chart mode by default"); - assert.strictEqual(graph.model.chart.dataPoints[0].value, 1, - "should have first datapoint with value 2"); - assert.strictEqual(graph.model.chart.dataPoints[1].value, 2, - "should have second datapoint with value 2"); - graph.destroy(); - done(); - }); + assert.strictEqual(graph.renderer.state.mode, "bar", + "should be in bar chart mode by default"); + assert.checkLabels(graph, [[true], [false]]); + assert.checkLegend(graph, 'Product'); + assert.checkDatasets(graph, 'data', {data: [1, 2]}); + + graph.destroy(); }); QUnit.test('use a many2one as a measure and as a groupby should work', async function (assert) { - assert.expect(2); + assert.expect(3); var graph = await createView({ View: GraphView, @@ -687,16 +666,14 @@ QUnit.module('Views', { additionalMeasures: ['product_id'], }, }); - await testUtils.nextTick(); // need to set the measure this way because it cannot be set in the // arch. await testUtils.dom.click(graph.$buttons.find('.dropdown-toggle:contains(Measures)')); await testUtils.dom.click(graph.$buttons.find('.dropdown-item[data-field="product_id"]')); - assert.strictEqual(graph.model.chart.dataPoints[0].value, 1, - "should have first datapoint with value 1"); - assert.strictEqual(graph.model.chart.dataPoints[1].value, 1, - "should have second datapoint with value 1"); + assert.checkLabels(graph, [['xphone'], ['xpad']]); + assert.checkLegend(graph, 'Product'); + assert.checkDatasets(graph, 'data', {data: [1, 1]}); graph.destroy(); }); @@ -712,13 +689,9 @@ QUnit.module('Views', { '<field name="product_id"/>' + '</graph>', }); - var done = assert.async(); - return concurrency.delay(0).then(function () { - assert.notOk(graph.measures.product_id, - "should not have product_id as measure"); - graph.destroy(); - done(); - }); + assert.notOk(graph.measures.product_id, + "should not have product_id as measure"); + graph.destroy(); }); QUnit.test('use a many2one as a measure if set as additional fields', async function (assert) { @@ -735,13 +708,9 @@ QUnit.module('Views', { additionalMeasures: ['product_id'], }, }); - var done = assert.async(); - return concurrency.delay(0).then(function () { - assert.ok(graph.measures.product_id, - "should have product_id as measure"); - graph.destroy(); - done(); - }); + assert.ok(graph.measures.product_id, + "should have product_id as measure"); + graph.destroy(); }); QUnit.test('graph view crash when moving from search view using Down key', async function (assert) { @@ -774,7 +743,7 @@ QUnit.module('Views', { '<field name="foo" type="measure"/>' + '<field name="bouh" type="measure"/>' + '</graph>', - }) + }); assert.strictEqual(graph.$buttons.find('.o_graph_measures_list .dropdown-item:first').data('field'), 'bouh', "Bouh should be the first measure"); @@ -785,7 +754,7 @@ QUnit.module('Views', { }); QUnit.test('Undefined should appear in bar, pie graph but not in line graph', async function (assert) { - assert.expect(4); + assert.expect(3); var graph = await createView({ View: GraphView, @@ -797,18 +766,23 @@ QUnit.module('Views', { '</graph>', }); - assert.strictEqual(graph.$("svg.nvd3-svg .nv-x:contains('Undefined')").length, 0); - assert.strictEqual(graph.$("svg.nvd3-svg:contains('January')").length, 1); + function _indexOf (label) { + return graph.renderer._indexOf(graph.renderer.chart.data.labels, label) + } + + assert.strictEqual(_indexOf(['Undefined']), -1); await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=bar]')); - assert.strictEqual(graph.$("svg.nvd3-svg .nv-x:contains('Undefined')").length, 1); - assert.strictEqual(graph.$("svg.nvd3-svg:contains('January')").length, 1); + assert.ok(_indexOf(['Undefined']) >= 0); + + await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=pie]')); + assert.ok(_indexOf(['Undefined']) >= 0); graph.destroy(); }); QUnit.test('Undefined should appear in bar, pie graph but not in line graph with multiple groupbys', async function (assert) { - assert.expect(6); + assert.expect(4); var graph = await createView({ View: GraphView, @@ -820,19 +794,664 @@ QUnit.module('Views', { '</graph>', }); - assert.strictEqual(graph.$("svg.nvd3-svg .nv-x:contains('Undefined')").length, 0); - assert.strictEqual(graph.$("svg.nvd3-svg:contains('January')").length, 1); + function _indexOf (label) { + return graph.renderer._indexOf(graph.renderer.chart.data.labels, label) + } + + assert.strictEqual(_indexOf(['Undefined']), -1); await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=bar]')); - assert.strictEqual(graph.$("svg.nvd3-svg .nv-x:contains('Undefined')").length, 1); - assert.strictEqual(graph.$("svg.nvd3-svg:contains('January')").length, 1); + assert.ok(_indexOf(['Undefined']) >= 0); + + await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=pie]')); + var labels = graph.renderer.chart.data.labels + assert.ok(labels.filter(label => /Undefined/.test(label.join(''))).length >= 1); // Undefined should not appear after switching back to line chart await testUtils.dom.click(graph.$buttons.find('.o_graph_button[data-mode=line]')); - assert.strictEqual(graph.$("svg.nvd3-svg .nv-x:contains('Undefined')").length, 0); - assert.strictEqual(graph.$("svg.nvd3-svg:contains('January')").length, 1); + assert.strictEqual(_indexOf(['Undefined']), -1); + graph.destroy(); }); -}); + QUnit.test('no comparison and no groupby', async function (assert) { + assert.expect(9); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners" type="bar">' + + '<field name="foo" type="measure"/>' + + '</graph>', + }); + + + assert.checkLabels(graph, [[]]); + assert.checkLegend(graph, 'Foo'); + assert.checkDatasets(graph, 'data', {data: [239]}); + + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=line]')); + // the labels in line chart is translated in this case to avoid to have a single + // point at the left of the screen and chart to seem empty. + assert.checkLabels(graph, [[''], [], ['']]); + assert.checkLegend(graph, 'Foo'); + assert.checkDatasets(graph, 'data', {data: [undefined, 239]}); + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=pie]')); + assert.checkLabels(graph, [[]]); + assert.checkLegend(graph, 'Total'); + assert.checkDatasets(graph, 'data', {data: [239]}); + + graph.destroy(); + }); + + QUnit.test('no comparison and one groupby', async function (assert) { + assert.expect(9); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners" type="bar">' + + '<field name="foo" type="measure"/>' + + '<field name="bar" type="row"/>' + + '</graph>', + }); + + assert.checkLabels(graph, [[true], [false]]); + assert.checkLegend(graph, 'Foo'); + assert.checkDatasets(graph, 'data', {data: [58, 181]}); + + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=line]')); + assert.checkLabels(graph, [[true], [false]]); + assert.checkLegend(graph, 'Foo'); + assert.checkDatasets(graph, 'data', {data: [58, 181]}); + + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=pie]')); + + assert.checkLabels(graph, [[true], [false]]); + assert.checkLegend(graph, ['true', 'false']); + assert.checkDatasets(graph, 'data', {data: [58, 181]}); + + graph.destroy(); + }); + QUnit.test('no comparison and two groupby', async function (assert) { + assert.expect(9); + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners" type="bar">' + + '<field name="foo" type="measure"/>' + + '</graph>', + groupBy: ['product_id', 'color_id'], + }); + + assert.checkLabels(graph, [['xphone'], ['xpad']]) + assert.checkLegend(graph, ['Undefined', 'red']); + assert.checkDatasets(graph, ['label', 'data'], [ + { + label: 'Undefined', + data: [29, 157], + }, + { + label: 'red', + data: [53, 0], + } + ]); + + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=line]')); + assert.checkLabels(graph, [['xphone'], ['xpad']]) + assert.checkLegend(graph, ['Undefined', 'red']); + assert.checkDatasets(graph, ['label', 'data'], [ + { + label: 'Undefined', + data: [29, 157], + }, + { + label: 'red', + data: [53, 0], + } + ]); + + await testUtils.dom.click(graph.$('.o_graph_button[data-mode=pie]')); + assert.checkLabels(graph, [['xphone', 'Undefined'], ['xphone', 'red'], ['xpad', 'Undefined']]) + assert.checkLegend(graph, ['xphone/Undefined', 'xphone/red', 'xpad/Undefined']); + assert.checkDatasets(graph, ['label', 'data'], { + label: '', + data: [29, 53, 157], + }); + + graph.destroy(); + }); + + QUnit.module('GraphView: comparison mode', { + beforeEach: async function () { + this.data.foo.records[0].date = '2016-12-15'; + this.data.foo.records[1].date = '2016-12-17'; + this.data.foo.records[2].date = '2016-11-22'; + this.data.foo.records[3].date = '2016-11-03'; + this.data.foo.records[4].date = '2016-12-20'; + this.data.foo.records[5].date = '2016-12-19'; + this.data.foo.records[6].date = '2016-12-15'; + this.data.foo.records[7].date = undefined; + + this.data.foo.records.push({id: 9, foo: 48, bar: false, product_id: 41, color_id: 7, date: "2016-12-01"}); + this.data.foo.records.push({id: 10, foo: 17, bar: true, product_id: 41, color_id: 7, date: "2016-12-01"}); + this.data.foo.records.push({id: 11, foo: 45, bar: true, product_id: 37, color_id: 14, date: "2016-12-01"}); + this.data.foo.records.push({id: 12, foo: 48, bar: false, product_id: 37, color_id: 7, date: "2016-12-10"}); + this.data.foo.records.push({id: 13, foo: 48, bar: false, product_id: undefined, color_id: 14, date: "2016-11-30"}); + this.data.foo.records.push({id: 14, foo: -50, bar: true, product_id: 41, color_id: 14, date: "2016-12-15"}); + this.data.foo.records.push({id: 15, foo: 53, bar: false, product_id: 41, color_id: 14, date: "2016-11-01"}); + this.data.foo.records.push({id: 16, foo: 53, bar: true, product_id: undefined, color_id: 7, date: "2016-09-01"}); + this.data.foo.records.push({id: 17, foo: 48, bar: false, product_id: 41, color_id: undefined, date: "2016-08-01"}); + this.data.foo.records.push({id: 18, foo: -156, bar: false, product_id: 37, color_id: undefined, date: "2016-07-15"}); + this.data.foo.records.push({id: 19, foo: 31, bar: false, product_id: 41, color_id: 14, date: "2016-12-15"}); + this.data.foo.records.push({id: 20, foo: 109, bar: true, product_id: 41, color_id: 7, date: "2015-06-01"}); + + this.unpatchDate = patchDate(2016, 11, 20, 1, 0, 0); + + var checkOnlyToCheck = true; + var exhaustiveTest = false || checkOnlyToCheck; + + var self = this; + async function* graphGenerator(combinations) { + var i = 0; + while (i < combinations.length) { + var combination = combinations[i]; + if (!checkOnlyToCheck || combination.toString() in self.combinationsToCheck) { + await self.setConfig(combination); + } + if (exhaustiveTest) { + i++; + } else { + i += Math.floor(1 + Math.random() * 20) + } + yield combination; + } + } + + this.combinationsToCheck = {}; + this.testCombinations = async function (combinations, assert) { + for await (var combination of graphGenerator(combinations)) { + // we can check particular combinations here + if (combination.toString() in self.combinationsToCheck) { + var graph = self.actionManager.getCurrentController().widget; + if (self.combinationsToCheck[combination].errorMessage) { + assert.strictEqual( + graph.$('.o_nocontent_help p').eq(1).text(), + self.combinationsToCheck[combination].errorMessage + ); + } else { + assert.checkLabels(graph, self.combinationsToCheck[combination].labels) + assert.checkLegend(graph, self.combinationsToCheck[combination].legend); + assert.checkDatasets(graph, ['label', 'data'], self.combinationsToCheck[combination].datasets); + } + } + } + }; + + // time range menu is assumed to be closed + this.selectTimeRanges = async function (timeRangeOption, comparisonTimeRangeOption) { + comparisonTimeRangeOption = comparisonTimeRangeOption || 'previous_period'; + // open time range menu + await testUtils.dom.click($('.o_control_panel .o_time_range_menu_button')); + // select range + await testUtils.fields.editInput($('.o_control_panel .o_time_range_selector'), timeRangeOption); + // check checkbox 'Compare To' + if (!$('.o_control_panel .o_time_range_menu .o_comparison_checkbox').prop('checked')) { + await testUtils.dom.click($('.o_control_panel .o_time_range_menu .o_comparison_checkbox')); + } + // select 'Previous period' or 'Previous year' acording to comparisonTimeRangeOption + await testUtils.fields.editInput($('.o_control_panel .o_comparison_time_range_selector'), comparisonTimeRangeOption); + // Click on 'Apply' button + await testUtils.dom.click($('.o_control_panel .o_time_range_menu .o_apply_range')); + }; + + // groupby menu is assumed to be closed + this.selectDateIntervalOption = async function (intervalOption) { + intervalOption = intervalOption || 'month'; + // open group by menu + await testUtils.dom.click($('.o_control_panel .o_dropdown span.fa-bars')); + // open option submenu + await testUtils.dom.click($('.o_control_panel .o_group_by_menu .o_menu_item:contains("Date")')); + // check interval option if not already selected + if (!$('.o_control_panel .o_group_by_menu .o_item_option[data-option_id="' + intervalOption + '"] a').hasClass('selected')) { + await testUtils.dom.click($('.o_control_panel .o_group_by_menu .o_item_option[data-option_id="' + intervalOption + '"]')); + } + // close group by menu + await testUtils.dom.click($('.o_control_panel .o_dropdown span.fa-bars')); + }; + + // groupby menu is assumed to be closed + this.selectGroupBy = async function (groupByName) { + // open group by menu + await testUtils.dom.click($('.o_control_panel .o_dropdown span.fa-bars')); + // check groupBy if not already selected + if (!$('.o_control_panel .o_group_by_menu .o_menu_item:contains(' + groupByName + ') a').hasClass('selected')) { + await testUtils.dom.click($('.o_control_panel .o_group_by_menu .o_menu_item:contains(' + groupByName + ')')); + } + // close group by menu + await testUtils.dom.click($('.o_control_panel .o_dropdown span.fa-bars')); + }; + + this.setConfig = async function (combination) { + await this.selectTimeRanges(combination[0], combination[1]); + if (combination.length === 3) { + await self.selectDateIntervalOption(combination[2]); + } + }; + + this.setMode = async function (mode) { + await testUtils.dom.click($('.o_control_panel .o_graph_button[data-mode=' + mode + ']')); + }; + + // // create an action manager to test the interactions with the search view + this.actionManager = await createActionManager({ + data: this.data, + archs: { + 'foo,false,graph': '<graph string="Partners" type="bar">' + + '<field name="foo" type="measure"/>' + + '</graph>', + 'foo,false,search': '<search>' + + '<filter name="date" string="Date" context="{\'group_by\': \'date\'}"/>' + + '<filter name="bar" string="Bar" context="{\'group_by\': \'bar\'}"/>' + + '<filter name="product_id" string="Product" context="{\'group_by\': \'product_id\'}"/>' + + '<filter name="color_id" string="Color" context="{\'group_by\': \'color_id\'}"/>' + + '</search>', + }, + }); + + await this.actionManager.doAction({ + res_model: 'foo', + type: 'ir.actions.act_window', + views: [[false, 'graph']], + flags: { + graph: { + additionalMeasures: ['product_id'], + } + } + }); + }, + afterEach: function () { + this.unpatchDate(); + this.actionManager.destroy(); + }, + }, function () { + QUnit.test('comparison with one groupby equal to comparison date field', async function (assert) { + assert.expect(10); + + this.combinationsToCheck = { + 'last_30_days,previous_period,day': { + labels: [...Array(7).keys()].map(x => [x]), + legend: ["Last 30 Days", "Previous Period"], + datasets: [ + { + data: [26, 53, 2, 63, 110, 48, 48], + label: "Last 30 Days", + }, + { + data: [24, 53], + label: "Previous Period", + } + ], + } + }; + + var combinations = COMBINATIONS_WITH_DATE; + await this.testCombinations(combinations, assert); + await this.setMode('line'); + await this.testCombinations(combinations, assert); + this.combinationsToCheck['last_30_days,previous_period,day'] = { + labels: [...Array(7).keys()].map(x => [x]), + legend: [ + "2016-12-15,2016-11-03", + "2016-12-17,2016-11-01", + "2016-11-22", + "2016-12-19", + "2016-12-01", + "2016-12-10", + "2016-11-30", + ], + datasets: [ + { + data: [26, 53, 2, 63, 110, 48, 48], + label: "Last 30 Days", + }, + { + data: [24, 53, 0, 0, 0, 0, 0], + label: "Previous Period", + } + ], + }; + await this.setMode('pie'); + await this.testCombinations(combinations, assert); + + assert.ok(true, "No combination causes a crash"); + }); + + QUnit.test('comparison with no groupby', async function (assert) { + assert.expect(10); + + this.combinationsToCheck = { + 'last_30_days,previous_period': { + labels: [[]], + legend: ["Last 30 Days", "Previous Period"], + datasets: [ + { + data: [350], + label: "Last 30 Days", + }, + { + data: [77], + label: "Previous Period", + } + ], + } + }; + + var combinations = COMBINATIONS; + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['last_30_days,previous_period'] = { + labels: [[''], [], ['']], + legend: ["Last 30 Days", "Previous Period"], + datasets: [ + { + data: [undefined, 350], + label: "Last 30 Days", + }, + { + data: [undefined, 77], + label: "Previous Period", + } + ], + }; + await this.setMode('line'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['last_30_days,previous_period'] = { + labels: [[]], + legend: ["Total"], + datasets: [ + { + data: [350], + label: "Last 30 Days", + }, + { + data: [77], + label: "Previous Period", + } + ], + }; + await this.setMode('pie'); + await this.testCombinations(combinations, assert); + + assert.ok(true, "No combination causes a crash"); + }); + + QUnit.test('comparison with one groupby different from comparison date field', async function (assert) { + assert.expect(10); + + this.combinationsToCheck = { + 'last_30_days,previous_period': { + labels: [["xphone"],["xpad"],["Undefined"]], + legend: ["Last 30 Days", "Previous Period"], + datasets: [ + { + data: [151, 151, 48], + label: "Last 30 Days", + }, + { + data: [24, 53, 0], + label: "Previous Period", + } + ], + } + }; + + var combinations = COMBINATIONS; + await this.selectGroupBy('Product'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['last_30_days,previous_period'] = { + labels: [["xphone"],["xpad"]], + legend: ["Last 30 Days", "Previous Period"], + datasets: [ + { + data: [151, 151], + label: "Last 30 Days", + }, + { + data: [24, 53], + label: "Previous Period", + } + ], + }; + await this.setMode('line'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['last_30_days,previous_period'] = { + labels: [["xphone"],["xpad"],["Undefined"]], + legend: ["xphone", "xpad", "Undefined"], + datasets: [ + { + data: [151, 151, 48], + label: "Last 30 Days", + }, + { + data: [24, 53, 0], + label: "Previous Period", + } + ], + }; + await this.setMode('pie'); + await this.testCombinations(combinations, assert); + + assert.ok(true, "No combination causes a crash"); + }); + + QUnit.test('comparison with two groupby with first groupby equal to comparison date field', async function (assert) { + assert.expect(10); + + this.combinationsToCheck = { + 'last_7_days,previous_period,day': { + labels: [...Array(3).keys()].map(x => [x]), + legend: [ + "Last 7 Days/xphone", + "Last 7 Days/xpad", + "Previous Period/xphone" + ], + datasets: [ + { + data: [3, 53, 0], + label: "Last 7 Days/xphone", + }, + { + data: [23, 0, 63], + label: "Last 7 Days/xpad", + }, + { + data: [48], + label: "Previous Period/xphone", + } + ], + } + }; + + var combinations = COMBINATIONS_WITH_DATE; + await this.selectDateIntervalOption(); + await this.selectGroupBy('Product'); + await this.testCombinations(combinations, assert); + await this.setMode('line'); + await this.testCombinations(combinations, assert); + + + this.combinationsToCheck['last_7_days,previous_period,day'] = { + labels: [[0,"xphone"], [1,"xphone"], [2, "xpad"], [0, "xpad"]], + legend: [ + "2016-12-15,2016-12-10/xphone", + "2016-12-17/xphone", + "2016-12-19/xpad", + "2016-12-15,2016-12-10/xpad" + ], + datasets: [ + { + data: [3, 53, 63, 23], + label: "Last 7 Days", + }, + { + data: [48, 0, 0, 0], + label: "Previous Period", + } + ], + }; + + await this.setMode('pie'); + await this.testCombinations(combinations, assert); + + assert.ok(true, "No combination causes a crash"); + }); + + QUnit.test('comparison with two groupby with second groupby equal to comparison date field', async function (assert) { + assert.expect(8); + + this.combinationsToCheck = { + 'this_year,previous_period,quarter': { + labels: [["xphone"], ["xpad"],["Undefined"]], + legend: [ + "This Year/Q4 2016", + "This Year/Q3 2016", + "Previous Period/Q2 2015" + ], + datasets: [ + { + data: [175, 208, 48], + label: "This Year/Q4 2016", + }, + { + data: [-156, 48, 53], + label: "This Year/Q3 2016", + }, + { + data: [0, 109, 0], + label: "Previous Period/Q2 2015", + }, + ] + } + }; + + var combinations = COMBINATIONS_WITH_DATE; + await this.selectGroupBy('Product'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['this_year,previous_period,quarter'] = { + labels: [["xphone"], ["xpad"]], + legend: [ + "This Year/Q4 2016", + "This Year/Q3 2016", + "Previous Period/Q2 2015" + ], + datasets: [ + { + data: [175, 208], + label: "This Year/Q4 2016", + }, + { + data: [-156, 48], + label: "This Year/Q3 2016", + }, + { + data: [0, 109], + label: "Previous Period/Q2 2015", + }, + ] + }; + await this.setMode('line'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['this_year,previous_period,quarter'] = { + errorMessage: 'Pie chart cannot mix positive and negative numbers. ' + + 'Try to change your domain to only display positive results' + }; + await this.setMode('pie'); + await this.testCombinations(combinations, assert); + + assert.ok(true, "No combination causes a crash"); + }); + QUnit.test('comparison with two groupby with no groupby equal to comparison date field', async function (assert) { + assert.expect(10); + + this.combinationsToCheck = { + 'last_month,previous_year': { + labels: [["xphone"],["Undefined"], ["xpad"]], + legend: ["Last Month/true", "Last Month/false"], + datasets: [ + { + data: [2, 0, 0], + label: "Last Month/true", + }, + { + data: [24, 48, 53], + label: "Last Month/false", + } + ], + } + }; + + var combinations = COMBINATIONS; + await this.selectGroupBy('Product'); + await this.selectGroupBy('Bar'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['last_month,previous_year'] = { + labels: [["xphone"], ["xpad"]], + legend: ["Last Month/true", "Last Month/false"], + datasets: [ + { + data: [2, 0], + label: "Last Month/true", + }, + { + data: [24, 53], + label: "Last Month/false", + } + ], + }; + await this.setMode('line'); + await this.testCombinations(combinations, assert); + + this.combinationsToCheck['last_month,previous_year'] = { + labels: [ + ["xphone", true], + ["xphone", false], + ["Undefined", false], + ["xpad", false], + ["No data"] + ], + legend: [ + "xphone/true", + "xphone/false", + "Undefined/false", + "xpad/false", + "No data" + ], + datasets: [ + { + data: [2, 24, 48, 53], + label: "Last Month", + }, + { + data: [undefined, undefined, undefined, undefined, 1], + label: "Previous Year", + } + ], + }; + await this.setMode('pie'); + await this.testCombinations(combinations, assert); + + assert.ok(true, "No combination causes a crash"); + }); + }); }); +});
\ No newline at end of file diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index eda7ee2d855..d14f295f2bc 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -577,6 +577,7 @@ <script type="text/javascript" src="/web/static/tests/core/class_tests.js"></script> <script type="text/javascript" src="/web/static/tests/core/rpc_tests.js"></script> <script type="text/javascript" src="/web/static/tests/core/domain_tests.js"></script> + <script type="text/javascript" src="/web/static/tests/core/data_comparison_utils_tests.js"></script> <script type="text/javascript" src="/web/static/tests/core/mixins_tests.js"></script> <script type="text/javascript" src="/web/static/tests/core/time_tests.js"></script> <script type="text/javascript" src="/web/static/tests/core/concurrency_tests.js"></script> |
