summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/web/static/src/js/core/data_comparison_utils.js106
-rw-r--r--addons/web/static/src/js/views/graph/graph_controller.js23
-rw-r--r--addons/web/static/src/js/views/graph/graph_model.js39
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js1160
-rw-r--r--addons/web/static/src/js/views/graph/graph_view.js9
-rw-r--r--addons/web/static/src/scss/graph_view.scss59
-rw-r--r--addons/web/static/src/xml/base.xml3
-rw-r--r--addons/web/static/src/xml/chart.xml29
-rw-r--r--addons/web/static/tests/core/data_comparison_utils_tests.js75
-rw-r--r--addons/web/static/tests/views/graph_tests.js1177
-rw-r--r--addons/web/views/webclient_templates.xml1
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 &amp;&amp; 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>