summaryrefslogtreecommitdiff
path: root/addons/web/static/src
diff options
context:
space:
mode:
authorRobot Odoo <xmo+roboto@odoo.com>2019-07-30 10:20:59 +0200
committerGitHub <noreply@github.com>2019-07-30 10:20:59 +0200
commit64bed2851e0145985cf60732ad41a4f8f5a5fbf1 (patch)
treed953e04ead5870ddfef8e48d3236a5920b78d6ab /addons/web/static/src
parent8a3284685856b6980e3b620abdbbb3e2c79f57c4 (diff)
parentf781a7acbbe305f09110e8d13c6451270e5596f0 (diff)
[MERGE] web: Various graph view improvements
This PR aims to bring several improvements to the graph view. The main points are: - custom tooltips in line mode to avoid problems with the Chart.js generic tooltips (those cannot go outside of the canvas boundary and information is lost). - tooltips when legend items hovered in case their texts were cut for reason of space. See task https://www.odoo.com/web#id=1963306&action=333&active_id=133&model=project.task&view_type=form&menu_id=4720 closes odoo/odoo#33910 Signed-off-by: VincentSchippefilt <VincentSchippefilt@users.noreply.github.com>
Diffstat (limited to 'addons/web/static/src')
-rw-r--r--addons/web/static/src/js/views/graph/graph_controller.js2
-rw-r--r--addons/web/static/src/js/views/graph/graph_model.js4
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js456
-rw-r--r--addons/web/static/src/js/widgets/old/dropdown_menu.js236
-rw-r--r--addons/web/static/src/scss/graph_view.scss67
-rw-r--r--addons/web/static/src/xml/base.xml21
6 files changed, 421 insertions, 365 deletions
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 9e2ebfc0a78..8cfed52a050 100644
--- a/addons/web/static/src/js/views/graph/graph_controller.js
+++ b/addons/web/static/src/js/views/graph/graph_controller.js
@@ -172,7 +172,7 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{
.find('.o_graph_button[data-mode="stack"]')
.data('stacked', state.stacked)
.toggleClass('active', state.stacked)
- .toggleClass('o_hidden', state.mode !== 'bar');
+ .toggleClass('o_hidden', state.mode !== 'bar' || !this.renderer.isStackable);
_.each(this.$measureList.find('.dropdown-item'), function (item) {
var $item = $(item);
$item.toggleClass('selected', $item.data('field') === state.measure);
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 0ab8d4c0570..cf17e7267ab 100644
--- a/addons/web/static/src/js/views/graph/graph_model.js
+++ b/addons/web/static/src/js/views/graph/graph_model.js
@@ -219,7 +219,7 @@ return AbstractModel.extend({
/**
* Since read_group is insane and returns its result on different keys
* depending of some input, we have to normalize the result.
- * Each group comming from the read_group produces a dataPoint
+ * Each group coming from the read_group produces a dataPoint
*
* @todo This is not good for race conditions. The processing should get
* the object this.chart in argument, or an array or something. We want to
@@ -247,7 +247,7 @@ return AbstractModel.extend({
// when a many2one field is used as a measure AND as a grouped
// field, bad things happen. The server will only return the
// grouped value and will not aggregate it. Since there is a
- // nameclash, we are then in the situation where this value is
+ // name clash, we are then in the situation where this value is
// an array. Fortunately, if we group by a field, then we can
// say for certain that the group contains exactly one distinct
// value for that field.
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 3cc8ce80eba..f967c56a179 100644
--- a/addons/web/static/src/js/views/graph/graph_renderer.js
+++ b/addons/web/static/src/js/views/graph/graph_renderer.js
@@ -18,15 +18,16 @@ 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 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) {
+
+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 parseInt(n, 16);
+ }).join(',');
return 'rgba(' + rgb + ',' + opacity + ')';
}
@@ -45,13 +46,11 @@ var FORMAT_OPTIONS = {
},
};
-
var NO_DATA = [_t('No data')];
NO_DATA.isNoData = true;
// hide top legend when too many items for device size
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",
@@ -72,22 +71,21 @@ return AbstractRenderer.extend({
this.chart = null;
this.chartId = _.uniqueId('chart');
+ // the bar charts are considered stackable a priori
+ this.isStackable = true;
+ this.$legendTooltip = null;
+ this.$tooltip = null;
},
/**
* @override
*/
- updateState: function () {
-
+ updateState: function () {
return this._super.apply(this, arguments);
},
/**
- * 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
- * here use the on_attach_callback hook, called when the widget is attached
- * to the DOM, to perform the rendering. This ensures that the rendering is
- * always done in the DOM.
+ * Chart.js does not need the canvas to be in dom in order
+ * to be able to work well. We could avoid the calls to on_attach_callback
+ * and on_detach_callback.
*
* @override
*/
@@ -109,6 +107,98 @@ return AbstractRenderer.extend({
//--------------------------------------------------------------------------
/**
+ * This function aims to remove a suitable number of lines from the tooltip in order to make it reasonably visible.
+ * A message indicating the number of lines is added if necessary.
+ *
+ * @private
+ * @param {Number} maxTooltipHeight this the max height in pixels of the tooltip
+ */
+ _adjustTooltipHeight: function (maxTooltipHeight) {
+ var sizeOneLine = this.$tooltip.find('tbody tr')[0].clientHeight;
+ var tbodySize = this.$tooltip.find('tbody')[0].clientHeight;
+ var toKeep = Math.floor((maxTooltipHeight - (this.$tooltip[0].clientHeight - tbodySize)) / sizeOneLine) - 1;
+ var $lines = this.$tooltip.find('tbody tr');
+ var toRemove = $lines.length - toKeep;
+ if (toRemove > 0) {
+ $lines.slice(toKeep).remove();
+ var tr = document.createElement('tr');
+ var td = document.createElement('td');
+ tr.classList.add('o_show_more');
+ td.innerHTML = _t("...");
+ tr.appendChild(td);
+ this.$tooltip.find('tbody').append(tr);
+ }
+ },
+ /**
+ * This function creates a custom HTML tooltip.
+ *
+ * @private
+ * @param {Object} tooltipModel see chartjs documentation
+ */
+ _customTooltip: function (tooltipModel) {
+ if (this.$tooltip) {
+ this.$tooltip.remove();
+ }
+ if (tooltipModel.opacity === 0) {
+ return;
+ }
+ if (tooltipModel.dataPoints.length === 0) {
+ return;
+ }
+
+ const chartArea = this.chart.chartArea;
+ const chartAreaLeft = chartArea.left;
+ const chartAreaRight = chartArea.right;
+ const chartAreaTop = chartArea.top;
+ const rendererTop = this.$el[0].getBoundingClientRect().top;
+
+ const maxTooltipLabelWidth = Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px';
+
+ const tooltipItems = this._getTooltipItems(tooltipModel);
+
+ this.$tooltip = $(qweb.render('GraphView.CustomTooltip', {
+ measure: this.fields[this.state.measure].string,
+ tooltipItems: tooltipItems,
+ maxWidth: maxTooltipLabelWidth,
+ }));
+ const $container = this.$el.find('.o_graph_canvas_container');
+ $container.append(this.$tooltip);
+
+ let top;
+ const tooltipHeight = this.$tooltip[0].clientHeight;
+ const minTopAllowed = Math.floor(chartAreaTop);
+ const maxTopAllowed = Math.floor(window.innerHeight - rendererTop - tooltipHeight) - 2;
+ const y = Math.floor(tooltipModel.y);
+ if (minTopAllowed <= maxTopAllowed) {
+ // Here we know that the full tooltip can fit in the screen.
+ // We put it in the position where Chart.js would put it
+ // if two conditions are respected:
+ // 1: the tooltip is not cut (because we know it is possible to not cut it)
+ // 2: the tooltip does not hide the legend.
+ // If it is not possible to use the Chart.js proposition (y)
+ // we use the best approximated value.
+ if (y <= maxTopAllowed) {
+ if (y >= minTopAllowed) {
+ top = y;
+ } else {
+ top = minTopAllowed;
+ }
+ } else {
+ top = maxTopAllowed;
+ }
+ } else {
+ // Here we know that we cannot satisfy condition 1 above,
+ // so we position the tooltip at the minimal position and
+ // cut it the minimum possible.
+ top = minTopAllowed;
+ const maxTooltipHeight = window.innerHeight - (rendererTop + chartAreaTop) -2;
+ this._adjustTooltipHeight(maxTooltipHeight);
+ }
+ this.$tooltip[0].style.top = Math.floor(top) + 'px';
+
+ this._fixTooltipLeftPosition(this.$tooltip[0], tooltipModel.x);
+ },
+ /**
* 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
@@ -141,20 +231,28 @@ return AbstractRenderer.extend({
return dataPoints;
},
/**
- * Used to avoid too long legend items
+ * Sets best left position of a tooltip approaching the proposal x
*
* @private
- * @param {string} label
- * @returns {string} shortened version of the input label
+ * @param {DOMElement} tooltip
+ * @param {number} x, left offset proposed
*/
- _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 + '/...';
+ _fixTooltipLeftPosition: function (tooltip, x) {
+ let left;
+ const tooltipWidth = tooltip.clientWidth;
+ const minLeftAllowed = Math.floor(this.chart.chartArea.left + 2);
+ const maxLeftAllowed = Math.floor(this.chart.chartArea.right - tooltipWidth -2);
+ x = Math.floor(x);
+ if (x <= maxLeftAllowed) {
+ if (x >= minLeftAllowed) {
+ left = x;
+ } else {
+ left = minLeftAllowed;
+ }
+ } else {
+ left = maxLeftAllowed;
}
- return shortLabel;
+ tooltip.style.left = left + 'px';
},
/**
* Used to format correctly the values in tooltips and yAxes
@@ -202,13 +300,13 @@ return AbstractRenderer.extend({
* @param {Object} dataPt
* @returns {string}
*/
- _getDatasetLabel:function (dataPt) {
+ _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) : '');
}
datasetLabel = datasetLabel || this.fields[this.state.measure].string;
return datasetLabel;
@@ -264,13 +362,16 @@ return AbstractRenderer.extend({
* Returns the options used to generate the chart legend.
*
* @private
- * @param {number} datasetsCount
+ * @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',
+ // position: this.state.mode === 'pie' ? 'right' : 'top',
+ position: 'top',
+ onHover: this._onlegendTooltipHover.bind(this),
+ onLeave: this._onLegendTootipLeave.bind(this),
};
var self = this;
if (_.contains(['bar', 'line'], this.state.mode)) {
@@ -281,11 +382,12 @@ return AbstractRenderer.extend({
referenceColor = 'borderColor';
}
legendOptions.labels = {
- generateLabels: function(chart) {
+ generateLabels: function (chart) {
var data = chart.data;
- return data.datasets.map(function(dataset, i) {
+ return data.datasets.map(function (dataset, i) {
return {
text: self._shortenLabel(dataset.label),
+ fullText: dataset.label,
fillStyle: dataset[referenceColor],
hidden: !chart.isDatasetVisible(i),
lineCap: dataset.borderCapStyle,
@@ -302,12 +404,12 @@ return AbstractRenderer.extend({
};
} else {
legendOptions.labels = {
- generateLabels: function(chart) {
+ 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) {
+ return data.labels.map(function (label, i) {
var hidden = metaData.reduce(
function (hidden, data) {
if (data[i]) {
@@ -317,9 +419,11 @@ return AbstractRenderer.extend({
},
false
);
- var text = self._shortenLabel(self._relabelling(label));
+ var fullText = self._relabelling(label);
+ var text = self._shortenLabel(fullText);
return {
text: text,
+ fullText: fullText,
fillStyle: label.isNoData ? '#d3d3d3' : self._getColor(i),
hidden: hidden,
index: i,
@@ -345,7 +449,7 @@ return AbstractRenderer.extend({
scaleLabel: {
display: this.state.groupBy.length && !this.isEmbedded,
labelString: this.state.groupBy.length ?
- this.fields[this.state.groupBy[0].split(':')[0]].string : '',
+ this.fields[this.state.groupBy[0].split(':')[0]].string : '',
},
ticks: {
// don't use bind: callback is called with 'index' as second parameter
@@ -361,9 +465,9 @@ return AbstractRenderer.extend({
display: !this.isEmbedded,
labelString: this.fields[this.state.measure].string,
},
- stacked: this.state.mode === 'bar' && this.state.stacked,
ticks: {
callback: this._formatValue.bind(this),
+ suggestedMax: 0,
suggestedMin: 0,
}
}],
@@ -372,90 +476,90 @@ return AbstractRenderer.extend({
return {};
},
/**
- * Returns the options used to generate chart tooltips.
+ * Extracts the important information from a tooltipItem generated by Charts.js
+ * (a tooltip item corresponds to a line (different from measure name) of a tooltip)
*
* @private
- * @param {number} datasetsCount
+ * @param {Object} item
+ * @param {Object} data
* @returns {Object}
*/
- _getTooltipOptions: function (datasetsCount) {
+ _getTooltipItemContent: function (item, data) {
+ var dataset = data.datasets[item.datasetIndex];
+ var label = data.labels[item.index];
+ var value;
+ var boxColor;
+ if (this.state.mode === 'bar') {
+ label = this._relabelling(label, dataset.originIndex);
+ if (this.state.groupBy.length > 1 || this.state.origins.length > 1) {
+ label = label + "/" + dataset.label;
+ }
+ value = this._formatValue(item.yLabel);
+ boxColor = dataset.backgroundColor;
+ } else if (this.state.mode === 'line') {
+ label = this._relabelling(label, dataset.originIndex);
+ if (this.state.groupBy.length > 1 || this.state.origins.length > 1) {
+ label = label + "/" + dataset.label;
+ }
+ value = this._formatValue(item.yLabel);
+ boxColor = dataset.borderColor;
+ } else {
+ if (label.isNoData) {
+ value = this._formatValue(0);
+ } else {
+ value = this._formatValue(dataset.data[item.index]);
+ }
+ label = this._relabelling(label, dataset.originIndex);
+ if (this.state.origins.length > 1) {
+ label = dataset.label + "/" + label;
+ }
+ boxColor = dataset.backgroundColor[item.index];
+ }
+ return {
+ label: label,
+ value: value,
+ boxColor: boxColor,
+ };
+ },
+ /**
+ * This function extracts the information from the data points in tooltipModel.dataPoints
+ * (corresponding to datapoints over a given label determined by the mouse position)
+ * that will be displayed in a custom tooltip.
+ *
+ * @private
+ * @param {Object} tooltipModel see chartjs documentation
+ * @return {Object[]}
+ */
+ _getTooltipItems: function (tooltipModel) {
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;
- },
+ var data = this.chart.config.data;
+
+ var orderedItems = tooltipModel.dataPoints.sort(function (dPt1, dPt2) {
+ return dPt2.yLabel - dPt1.yLabel;
+ });
+ return orderedItems.reduce(
+ function (acc, item) {
+ acc.push(self._getTooltipItemContent(item, data));
+ return acc;
},
+ []
+ );
+ },
+ /**
+ * Returns the options used to generate chart tooltips.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getTooltipOptions: function () {
+ var tooltipOptions = {
+ // disable Chart.js tooltips
+ enabled: false,
+ custom: this._customTooltip.bind(this),
};
- 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,
- };
- },
- });
+ if (this.state.mode === 'line') {
+ tooltipOptions.mode = 'index';
+ tooltipOptions.intersect = false;
}
return tooltipOptions;
},
@@ -488,7 +592,7 @@ return AbstractRenderer.extend({
return index;
},
/**
- * Separate dataPoints comming from the read_group(s) into different datasets.
+ * Separate dataPoints coming from the read_group(s) into different datasets.
* This function returns the parameters data and labels used to produce the charts.
*
* @private
@@ -498,7 +602,7 @@ return AbstractRenderer.extend({
* @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.
+ * @returns {Object} the parameter data used to instantiate the chart.
*/
_prepareData: function (dataPoints) {
var self = this;
@@ -562,13 +666,13 @@ return AbstractRenderer.extend({
maintainAspectRatio: false,
scales: this._getScaleOptions(),
legend: this._getLegendOptions(datasetsCount),
- tooltips: this._getTooltipOptions(datasetsCount),
+ tooltips: this._getTooltipOptions(),
};
},
/**
* 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.
+ * it is when restricted to the set of dataPoints coming from a same origin.
* @private
* @param {Array} label
@@ -662,9 +766,35 @@ return AbstractRenderer.extend({
// prepare data
var data = this._prepareData(dataPoints);
+
+ // The datapoints (with non zero value) can be grouped by
+ // their originIndex and associated labels.
+ // If at least one group formed in that way has two elements
+ // this.isStackable will be true.
+ var groupMemberCounts = data.labels.map(function () {
+ return {};
+ });
+ this.isStackable = false;
+ for (var i = 0; i < data.datasets.length; i++) {
+ var dataset = data.datasets[i];
+ var originIndex = dataset.originIndex;
+ if (this.isStackable) {
+ break;
+ }
+ for (var j = 0; j < dataset.data.length; j++) {
+ if (dataset.data[j]) {
+ if (originIndex in groupMemberCounts[j]) {
+ this.isStackable = true;
+ break;
+ } else {
+ groupMemberCounts[j][originIndex] = 1;
+ }
+ }
+ }
+ }
data.datasets.forEach(function (dataset, index) {
// used when stacked
- dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined;
+ dataset.stack = self.isStackable && self.state.stacked ? self.state.origins[dataset.originIndex] : undefined;
// set dataset color
var color = self._getColor(index);
dataset.backgroundColor = color;
@@ -711,17 +841,23 @@ return AbstractRenderer.extend({
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
+ // shift 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)';
});
- // center the points in the chart (whithout that code they are put on the left and the graph seems empty)
+ if (data.datasets.length === 1) {
+ const dataset = data.datasets[0];
+ dataset.fill = 'origin';
+ dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4);
+ }
+
+ // center the points in the chart (without 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 ,[['']]]);
+ data.labels :
+ Array.prototype.concat.apply([], [[['']], data.labels, [['']]]);
// prepare options
var options = this._prepareOptions(data.datasets.length);
@@ -834,10 +970,80 @@ return AbstractRenderer.extend({
*/
_renderTitle: function () {
if (this.title) {
- this.$('.o_graph_canvas_container').last().prepend($('<label/>', {
+ this.$el.prepend($('<label/>', {
text: this.title,
}));
}
},
+ /**
+ * 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 (shortLabel.length > 30) {
+ shortLabel = shortLabel.slice(0, 30) + '...';
+ } else if (groups.length > 3) {
+ shortLabel = shortLabel + '/...';
+ }
+ return shortLabel;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * If the text of a legend item has been shortened and the user mouse over
+ * that item (actually the event type is mousemove), a tooltip with the item
+ * full text is displayed.
+ *
+ * @private
+ * @param {MouseEvent} e
+ * @param {Object} legendItem
+ */
+ _onlegendTooltipHover: function (e, legendItem) {
+ // The string legendItem.text is an initial segment of legendItem.fullText.
+ // If the two coincide, no need to generate a tooltip.
+ // If a tooltip for the legend already exists, it is already good and don't need
+ // to be recreated.
+ if (legendItem.text === legendItem.fullText || this.$legendTooltip) {
+ return;
+ }
+
+ const chartAreaLeft = this.chart.chartArea.left;
+ const chartAreaRight = this.chart.chartArea.right;
+ const rendererTop = this.$el[0].getBoundingClientRect().top;
+
+ this.$legendTooltip = $('<div>', {
+ class: "o_tooltip_legend",
+ text: legendItem.fullText,
+ css: {
+ maxWidth: Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px',
+ top: (e.clientY - rendererTop) + 'px',
+ }
+ });
+ const $container = this.$el.find('.o_graph_canvas_container');
+ $container.append(this.$legendTooltip);
+
+ this._fixTooltipLeftPosition(this.$legendTooltip[0], e.clientX);
+ },
+ /**
+ * If there's a legend tooltip and the user mouse out of the corresponding
+ * legend item, the tooltip is removed.
+ *
+ * @private
+ */
+ _onLegendTootipLeave: function () {
+ if (this.$legendTooltip) {
+ this.$legendTooltip.remove();
+ this.$legendTooltip = null;
+ }
+ },
});
});
diff --git a/addons/web/static/src/js/widgets/old/dropdown_menu.js b/addons/web/static/src/js/widgets/old/dropdown_menu.js
deleted file mode 100644
index 429fdd03067..00000000000
--- a/addons/web/static/src/js/widgets/old/dropdown_menu.js
+++ /dev/null
@@ -1,236 +0,0 @@
-odoo.define('web.OldDropdownMenu', function (require) {
-"use strict";
-
-var core = require('web.core');
-var Widget = require('web.Widget');
-
-var QWeb = core.qweb;
-
-
-
-var OldDropdownMenu = Widget.extend({
- template: 'web.OldDropdownMenu',
- events: {
- 'click .o_menu_item': '_onItemClick',
- 'click .o_item_option': '_onOptionClick',
- 'click span.o_trash_button': '_onTrashClick',
- 'hidden.bs.dropdown': '_onBootstrapClose',
- },
-
- /**
- * override
- *
- * @param {Widget} parent
- * @param {Object} dropdownHeader object used to customize the dropdown menu.
- * @param {String} dropdownHeader.title
- * @param {String} dropdownHeader.icon
- * @param {String} dropdownHeader.symbol
- * @param {String} dropdownHeader.category descripbes the type of items
- * @param {String} dropdownHeader.style the button style
- * @param {Object[]} items list of menu items
- *
- * Menu items:
- *
- * * itemId: string; (optional) unique id associated with the item
- * * description: string; label printed on screen
- * * groupId: string;
- * * isActive: boolean; (optional) determines if the item is considered active
- * * isOpen: boolean; (optional) in case there are options the submenu presenting the options is opened or closed according to isOpen
- * * isRemovable: boolean; (optional) can be removed from menu options: array of objects with 'optionId' and 'description' keys; (optional)
- * * currentOptionId: string refers to an optionId that is activated if item is active (optional)
- */
- init: function (parent, dropdownHeader, items) {
- this._super(parent);
- this.dropdownCategory = dropdownHeader.category;
- this.dropdownTitle = dropdownHeader.title;
- this.dropdownIcon = dropdownHeader.icon;
- this.dropdownSymbol = dropdownHeader.symbol || false;
- // this parameter fixes the menu style. By default,
- // the style used is the one used in the search view
- this.dropdownStyle = dropdownHeader.style || {
- el: {class: 'btn-group o_dropdown', attrs: {}},
- mainButton: {class: 'o_dropdown_toggler_btn btn btn-secondary dropdown-toggle' + (this.dropdownSymbol ? ' o-no-caret' : '')},
- };
- this.items = items;
- _.each(this.items, this._prepareItem.bind(this));
- },
- /**
- * override
- */
- start: function () {
- this.$menu = this.$('.o_dropdown_menu');
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @param {string[]} activeItemIds
- */
- updateItemsStatus: function (activeItemIds) {
- _.each(this.items, function (item) {
- if (!_.contains(activeItemIds, item.itemId)) {
- item.isActive = false;
- item.currentOptionId = false;
- } else {
- item.isActive = true;
- item.currentOptionId = item.hasOptions ?
- (item.currentOptionId || item.defaultOptionId) :
- false;
- }
- });
- this._renderMenuItems();
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {string} itemId
- */
- _removeItem: function (itemId) {
- var item, itemIndex;
- item = _.findWhere(this.items, {itemId: itemId});
- itemIndex = this.items.indexOf(item);
- this.items.splice(itemIndex, 1);
- this._renderMenuItems();
- var eventData = {
- category: this.dropdownCategory,
- itemId: itemId,
- groupId: item.groupId,
- };
- this.trigger_up('menu_item_deleted', eventData);
- },
- /**
- * @private
- */
- _renderMenuItems: function () {
- var newMenuItems = QWeb.render('OldDropdownMenu.MenuItems', {widget: this});
- this.$el.find('.o_menu_item, .dropdown-divider[data-removable="1"]').remove();
- this.$('.o_dropdown_menu').prepend($(newMenuItems));
- },
- /**
- * this function computes some default values for some of the item keys
- * according to values passed at initialization
- *
- * @private
- * @param {Object} item
- */
- _prepareItem: function (item) {
- item.itemId = item.itemId || _.uniqueId('__item__');
- item.isOpen = item.isOpen || false;
- item.isRemovable = item.isRemovable || false;
- if (item.options && item.options.length !== 0) {
- item.options = _.sortBy(item.options, 'groupId');
- item.hasOptions = true;
- item.defaultOptionId = item.defaultOptionId || this.defaultOptionId;
- item.currentOptionId = item.isActive && item.defaultOptionId ? item.defaultOptionId : false;
- }
- },
- /**
- * @private
- * @param {string} itemId
- */
- _toggleMenuItem: function (itemId) {
- var item = _.findWhere(this.items, {itemId: itemId});
- if (!item.hasOptions) {
- item.isActive = !item.isActive;
- var eventData = {
- category: this.dropdownCategory,
- itemId: itemId,
- isActive: item.isActive,
- groupId: item.groupId,
- };
- this._renderMenuItems();
- this.trigger_up('menu_item_toggled', eventData);
- }
- if (item.hasOptions) {
- item.isOpen = !item.isOpen;
- this._renderMenuItems();
- }
- },
- /**
- * @private
- * @param {string} itemId
- * @param {string} optionId
- */
- _toggleItemOption: function (itemId, optionId) {
- var item = _.findWhere(this.items, {itemId: itemId});
- var initialState = item.isActive;
- if (item.currentOptionId === optionId) {
- item.isActive = false;
- item.currentOptionId = false;
- } else {
- item.isActive = true;
- item.currentOptionId = optionId;
- }
- var eventData = {
- category: this.dropdownCategory,
- itemId: itemId,
- groupId: item.groupId,
- isActive: item.isActive,
- optionId: item.currentOptionId,
- };
- this._renderMenuItems();
- if (item.isActive !== initialState) {
- this.trigger_up('menu_item_toggled', eventData);
- } else {
- this.trigger_up('item_option_changed', eventData);
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * This method is called when Bootstrap trigger a 'close' event (for example
- * when the user clicks outside the dropdown menu).
- *
- * @private
- * @param {jQueryEvent} event
- */
- _onBootstrapClose: function () {
- _.each(this.items, function (item) {
- item.isOpen = false;
- });
- this._renderMenuItems();
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onItemClick: function (event) {
- event.preventDefault();
- event.stopPropagation();
- var itemId = $(event.currentTarget).data('id');
- this._toggleMenuItem(itemId);
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onOptionClick: function (event) {
- event.preventDefault();
- event.stopPropagation();
- var optionId = $(event.currentTarget).data('option_id');
- var itemId = $(event.currentTarget).data('item_id');
- this._toggleItemOption(itemId, optionId);
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onTrashClick: function (event) {
- event.stopPropagation();
- var itemId = $(event.currentTarget).data('id');
- this._removeItem(itemId);
- },
-});
-
-return OldDropdownMenu;
-
-});
diff --git a/addons/web/static/src/scss/graph_view.scss b/addons/web/static/src/scss/graph_view.scss
index fdf11967daa..45e733ec60c 100644
--- a/addons/web/static/src/scss/graph_view.scss
+++ b/addons/web/static/src/scss/graph_view.scss
@@ -1,8 +1,8 @@
.o_graph_controller {
.o_graph_renderer {
height: 100%;
- overflow: auto;
.o_graph_canvas_container {
+ padding-top: 5px;
position: relative;
height: 100%;
width: 100%;
@@ -13,4 +13,69 @@
}
}
}
+
+ div.o_tooltip_legend {
+ background-color: white;
+ color:black;
+ font-size: 12px;
+ border: black solid 0.5px;
+ border-radius: 3px;
+ opacity: 1;
+ position: absolute;
+ padding:10px;
+ z-index: 2;
+ pointer-events: none;
+ word-break: break-all;
+ }
+
+ div.o_graph_custom_tooltip {
+ border-radius: 3px;
+ background-color: white;
+ border: black solid 0.5px;
+ position: absolute;
+ z-index: 1;
+ padding: 6px;
+ pointer-events: none;
+ opacity: 0.9;
+ font-family: Arial;
+ table {
+ font-size: 12px;
+ overflow: hidden;
+ display: block;
+ border-collapse: collapse;
+ }
+ th.o_measure {
+ color: black;
+ font-weight: bolder;
+ vertical-align: baseline;
+ padding-bottom: 1px;
+ }
+ td {
+ span.o_square {
+ height: 12px;
+ width: 12px;
+ display: inline-block;
+ vertical-align: middle;
+ }
+ span.o_label {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ color: black;
+ display: inline-block;
+ vertical-align: middle;
+ }
+ &.o_value {
+ color: black;
+ padding-left: 5px;
+ text-align: right;
+ font-weight: bold;
+ }
+ }
+ tr.o_show_more {
+ text-align: center;
+ color: black;
+ font-weight: bolder;
+ }
+ }
}
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 12e3f2188fa..da8e376e7d3 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -399,6 +399,27 @@
</div>
</t>
+<div t-name="GraphView.CustomTooltip" class="o_graph_custom_tooltip">
+ <table>
+ <thead>
+ <tr>
+ <th class="o_measure"><t t-esc="measure"/></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr t-foreach="tooltipItems" t-as="tooltipItem">
+ <td>
+ <span class="o_square" t-att-style="'background-color: ' + tooltipItem.boxColor"/>
+ <span class="o_label" t-att-style="'max-width: ' + maxWidth"> <t t-esc="tooltipItem.label" /> </span>
+ </td>
+ <td class="o_value">
+ <t t-esc="tooltipItem.value"/>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</div>
+
<div t-name="PivotView" t-attf-class="o_pivot #{widget.enableLinking ? 'o_enable_linking' : ''}">
<div class="o_field_selection"/>
</div>