summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMathieu Duckerts-Antoine <dam@odoo.com>2019-02-26 14:43:23 +0000
committerMathieu Duckerts-Antoine <dam@odoo.com>2019-04-01 14:40:51 +0000
commit604c43640a05ea30e085381c90584f31e8bf7f7c (patch)
tree8eb67b4911c04f59a6cb537b9b920a017bd8ca85
parenta8d3ca771f0d086767ed45a198db37ad3ebce029 (diff)
[IMP] web: use Chart.js in graph view
It has been decided to use Chart.js instead of nvd3 to render charts. In this commit the graph view has been largely rewritten to benefit from the options offered by Chart.js. Task ID: 1946138
-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>