diff options
| author | Robot Odoo <xmo+roboto@odoo.com> | 2019-07-30 10:20:59 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-07-30 10:20:59 +0200 |
| commit | 64bed2851e0145985cf60732ad41a4f8f5a5fbf1 (patch) | |
| tree | d953e04ead5870ddfef8e48d3236a5920b78d6ab /addons/web/static/src | |
| parent | 8a3284685856b6980e3b620abdbbb3e2c79f57c4 (diff) | |
| parent | f781a7acbbe305f09110e8d13c6451270e5596f0 (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.js | 2 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_model.js | 4 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_renderer.js | 456 | ||||
| -rw-r--r-- | addons/web/static/src/js/widgets/old/dropdown_menu.js | 236 | ||||
| -rw-r--r-- | addons/web/static/src/scss/graph_view.scss | 67 | ||||
| -rw-r--r-- | addons/web/static/src/xml/base.xml | 21 |
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> |
