From 769806ad136c681acfe5a9d7a42d0cd93fde8798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Thu, 28 Mar 2019 10:02:26 +0000 Subject: [PATCH] [REF] reimplement journal dashboard widget with Chart.js This commit is one part of the task 'removing nvd3'. We use now Chart.js to draw charts. Task ID: 1946138 --- .../src/scss/account_journal_dashboard.scss | 77 +------ .../web/static/src/js/fields/basic_fields.js | 215 ++++++++---------- .../web/static/src/scss/kanban_dashboard.scss | 1 - .../static/tests/fields/basic_fields_tests.js | 127 +---------- 4 files changed, 112 insertions(+), 308 deletions(-) diff --git a/addons/account/static/src/scss/account_journal_dashboard.scss b/addons/account/static/src/scss/account_journal_dashboard.scss index b701ccd8b44..711021fbbad 100644 --- a/addons/account/static/src/scss/account_journal_dashboard.scss +++ b/addons/account/static/src/scss/account_journal_dashboard.scss @@ -39,84 +39,19 @@ .o_sample_data_label { @include o-position-absolute($bottom: 25%, $right: 0); + z-index: 1; } } // Style for the widget "dashboard_graph" .o_dashboard_graph { - &.o_graph_linechart { - > svg { - height: 75px; - // Display cut bullets to full bullets - g.nvd3.nv-wrap.nv-line clipPath { - display: none; - } + position: relative; + margin: 16px -16px; - // X axis - g.nv-x.nv-axis { - transform: translateY(50px); - - // Remove max and min labels - g.nv-axisMaxMin { - display: none; - } - g.tick.major { - // Don't know why, but leads to strange bold text - // stroke: $o-brand-odoo; - - line { - display: none; - } - } - } - - // !important are needed because nvd3 uses inline style on elements - - // Remove strokes - g.nv-linesWrap { - g.nv-group.nv-series-0 { - fill-opacity: 0.3 !important; - fill: $o-brand-odoo !important; - stroke: $o-brand-odoo !important; - - .nv-point { - visibility: hidden; - r: 3; - stroke: $o-brand-odoo !important; - fill: white; - stroke-width: 2; - } - .nv-point:nth-child(n+1) { - visibility: visible; - fill-opacity: .95 !important; - stroke-opacity: .95 !important; - } - } - } - } - } - &.o_graph_barchart { - > svg { - height: 75px; - overflow: visible; - g.nv-barsWrap { - g.nv-group.nv-series-0 { - g.past { - opacity: 0.5; - fill: $o-brand-odoo !important; - } - g.future { - opacity: 0.5; - fill: $o-brand-primary !important; - } - g.o_sample_data { - opacity: 0.1; - fill: gray !important; - } - } - } - } + canvas { + height: 75px; } + } .o_sample_data .o_dashboard_graph.o_graph_linechart > svg g.nv-linesWrap g.nv-group.nv-series-0 { diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 7439c36037f..4940839e612 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -2483,35 +2483,16 @@ var FieldToggleBoolean = AbstractField.extend({ var JournalDashboardGraph = AbstractField.extend({ className: "o_dashboard_graph", - 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', ], init: function () { this._super.apply(this, arguments); this.graph_type = this.attrs.graph_type; this.data = JSON.parse(this.value); }, - start: function () { - this._onResize = this._onResize.bind(this); - nv.utils.windowResize(this._onResize); - return this._super.apply(this, arguments); - }, - destroy: function () { - if ('nv' in window && nv.utils && nv.utils.offWindowResize) { - // if the widget is destroyed before the lazy loaded libs (nv) are - // actually loaded (i.e. after the widget has actually started), - // nv is undefined, but the handler isn't bound yet anyway - nv.utils.offWindowResize(this._onResize); - } - this._super.apply(this, arguments); - }, /** - * The widget view uses the nv(d3) lib to render the graph. This lib + * The widget view uses the ChartJS 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 @@ -2535,23 +2516,7 @@ var JournalDashboardGraph = AbstractField.extend({ //-------------------------------------------------------------------------- /** - * @private - */ - _customizeChart: function () { - if (this.graph_type === 'bar') { - // Add classes related to time on each bar of the bar chart - var bar_classes = _.map(this.data[0].values, function (v) {return v.type; }); - - _.each(this.$('.nv-bar'), function (v, k){ - // classList doesn't work with phantomJS & addClass doesn't work with a SVG element - $(v).attr('class', $(v).attr('class') + ' ' + bar_classes[k]); - }); - } - }, - /** - * Render the widget only when it is in the DOM. This is because nvd3 - * renders graph only when it is in DOM, apparently to compute available - * height and width for instance. + * Render the widget only when it is in the DOM. * * @override * @private @@ -2568,89 +2533,101 @@ var JournalDashboardGraph = AbstractField.extend({ * @private */ _renderInDOM: function () { - // note: the rendering of this widget is aynchronous as nvd3 does a - // setTimeout(0) before executing the callback given to addGraph - var self = this; this.$el.empty(); - this.chart = null; - nv.addGraph(function () { - self.$svg = self.$el.append(''); - switch (self.graph_type) { - case "line": - self.$svg.addClass('o_graph_linechart'); - - self.chart = nv.models.lineChart(); - self.chart.forceY([0]); - self.chart.options({ - x: function (d, u) { return u; }, - margin: {'left': 0, 'right': 0, 'top': 5, 'bottom': 0}, - showYAxis: false, - showLegend: false, - }); - self.chart.xAxis.tickFormat(function (d) { - var label = ''; - _.each(self.data, function (v){ - if (v.values[d] && v.values[d].x){ - label = v.values[d].x; - } - }); - return label; - }); - self.chart.yAxis.tickFormat(d3.format(',.2f')); - - self.chart.tooltip.contentGenerator(function (key) { - return qweb.render('GraphCustomTooltip', { - 'color': key.point.color, - 'key': self.data[0].key, - 'value': d3.format(',.2f')(key.point.y) - }); - }); - break; - - case "bar": - self.$svg.addClass('o_graph_barchart'); - - self.chart = nv.models.discreteBarChart() - .x(function (d) { return d.label; }) - .y(function (d) { return d.value; }) - .showValues(false) - .showYAxis(false) - .color(['#875A7B', '#526774', '#FA8072']) - .margin({'left': 0, 'right': 0, 'top': 20, 'bottom': 20}); - - self.chart.xAxis.axisLabel(self.data[0].title); - self.chart.yAxis.tickFormat(d3.format(',.2f')); - - self.chart.tooltip.contentGenerator(function (key) { - return qweb.render('GraphCustomTooltip', { - 'color': key.color, - 'key': self.data[0].key, - 'value': d3.format(',.2f')(key.data.value) - }); - }); - break; - } - d3.select(self.$('svg')[0]) - .datum(self.data) - .transition().duration(600) - .call(self.chart); - - self._customizeChart(); - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onResize: function () { - if (this.chart) { - this.chart.update(); - this._customizeChart(); + var config, cssClass; + if (this.graph_type === 'line') { + config = this._getLineChartConfig(); + cssClass = 'o_graph_linechart'; + } else if (this.graph_type === 'bar') { + config = this._getBarChartConfig(); + cssClass = 'o_graph_barchart'; } + this.$canvas = $(''); + this.$el.addClass(cssClass); + this.$el.empty(); + this.$el.append(this.$canvas); + var context = this.$canvas[0].getContext('2d'); + this.chart = new Chart(context, config); + }, + _getLineChartConfig: function () { + var labels = this.data[0].values.map(function (pt) { + return pt.x; + }); + var borderColor = this.data[0].is_sample_data ? '#dddddd' : '#875a7b'; + var backgroundColor = this.data[0].is_sample_data ? '#ebebeb' : '#dcd0d9'; + return { + type: 'line', + data: { + labels: labels, + datasets: [{ + data: this.data[0].values, + fill: 'start', + label: this.data[0].key, + backgroundColor: backgroundColor, + borderColor: borderColor, + borderWidth: 2, + }] + }, + options: { + legend: {display: false}, + scales: { + yAxes: [{display: false}], + xAxes: [{display: false}] + }, + maintainAspectRatio: false, + elements: { + line: { + tension: 0.000001 + } + }, + tooltips: { + intersect: false, + position: 'nearest', + caretSize: 0, + }, + }, + }; + }, + _getBarChartConfig: function () { + var data = []; + var labels = []; + var backgroundColor = []; + + this.data[0].values.forEach(function (pt) { + data.push(pt.value); + labels.push(pt.label); + var color = pt.type === 'past' ? '#ccbdc8' : (pt.type === 'future' ? '#a5d8d7' : '#ebebeb'); + backgroundColor.push(color); + }); + return { + type: 'bar', + data: { + labels: labels, + datasets: [{ + data: data, + fill: 'start', + label: this.data[0].key, + backgroundColor: backgroundColor, + }] + }, + options: { + legend: {display: false}, + scales: { + yAxes: [{display: false}], + }, + maintainAspectRatio: false, + tooltips: { + intersect: false, + position: 'nearest', + caretSize: 0, + }, + elements: { + line: { + tension: 0.000001 + } + }, + }, + }; }, }); diff --git a/addons/web/static/src/scss/kanban_dashboard.scss b/addons/web/static/src/scss/kanban_dashboard.scss index c622858a8e7..339d62671bd 100644 --- a/addons/web/static/src/scss/kanban_dashboard.scss +++ b/addons/web/static/src/scss/kanban_dashboard.scss @@ -187,7 +187,6 @@ .o_dashboard_graph { overflow: hidden; - margin: ($o-kanban-dashboard-vpadding * 2) (-$o-kanban-dashboard-hpadding); } } diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 17c7a2bc3c0..c184b0c9382 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -2269,10 +2269,7 @@ QUnit.module('basic_fields', { }); QUnit.test('graph dashboard widget attach/detach callbacks', async function (assert) { - // This widget is rendered with nvd3, and nvd3 renders the graphs when - // the svg is in the DOM. The intent of this test is to determine when - // the field widgets are in the DOM, so that we can tell nvd3 to render - // them. + // This widget is rendered with Chart.js. var done = assert.async(); assert.expect(6); @@ -2319,9 +2316,8 @@ QUnit.module('basic_fields', { QUnit.test('graph dashboard widget is rendered correctly', async function (assert) { var done = assert.async(); - assert.expect(4); + assert.expect(3); - var graph_key = JSON.parse(this.data.partner.records[0].graph_data)[0].key; createView({ View: KanbanView, model: 'partner', @@ -2336,34 +2332,20 @@ QUnit.module('basic_fields', { '', domain: [['id', 'in', [1, 2]]], }).then(function (kanban) { - // nvd3 seems to do a setTimeout(0) each time the addGraph function is - // called, which is done twice in this case as there are 2 records. - // for that reason, we need to do two setTimeout(0) as well here to ensure - // that both graphs are rendered before starting to check if the rendering - // is correct. concurrency.delay(0).then(function () { - return concurrency.delay(0); - }).then(function () { - assert.strictEqual(kanban.$('.o_kanban_record:first() .o_graph_barchart svg').length, 1, + assert.strictEqual(kanban.$('.o_kanban_record:first() .o_graph_barchart').length, 1, "graph of first record should be a barchart"); - assert.strictEqual(kanban.$('.o_kanban_record:nth(1) .o_graph_linechart svg').length, 1, + assert.strictEqual(kanban.$('.o_kanban_record:nth(1) .o_dashboard_graph').length, 1, "graph of second record should be a linechart"); - var evt = document.createEvent("MouseEvents"); //taken ref from https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/initMouseEvent - evt.initMouseEvent("mouseover", true, true, window, 0, 0, 0, 80, 20, false, false, false, false, 0, null); - $('.discreteBar')[0].dispatchEvent(evt); - var tooltip = $('.nvtooltip').find('table').find('.key')[0].innerText.trim(); - assert.equal(tooltip, graph_key, "graph tooltip should be generated "); - $('.nvtooltip').remove(); - // force a re-rendering of the first record (to check if the // previous rendered graph is correctly removed from the DOM) var firstRecordState = kanban.model.get(kanban.handle).data[0]; return kanban.renderer.updateRecord(firstRecordState); }).then(function () { - return concurrency.delay(0); // one graph is re-rendered + return concurrency.delay(0); }).then(function () { - assert.strictEqual(kanban.$('.o_kanban_record:first() svg').length, 1, + assert.strictEqual(kanban.$('.o_kanban_record:first() canvas').length, 1, "there should be only one rendered graph by record"); kanban.destroy(); @@ -2372,87 +2354,6 @@ QUnit.module('basic_fields', { }); }); - QUnit.test('graph dashboard widget does not need nv to be destroyed', async function (assert) { - // this test ensures that the JournalDashboardGraph widget doesn't crash - // when being destroyed before nv has been loaded - assert.expect(2); - - testUtils.mock.patch(basicFields.JournalDashboardGraph, { - destroy: function () { - assert.step('destroy'); - var nv = window.nv; - delete window.nv; - this._super.apply(this, arguments); - window.nv = nv; - } - }); - - var kanban = await createView({ - View: KanbanView, - model: 'partner', - data: this.data, - arch: '' + - '' + - '' + - '
' + - '' + - '
' + - '' + - '', - domain: [['id', 'in', [1]]], - }); - - kanban.destroy(); - testUtils.mock.unpatch(basicFields.JournalDashboardGraph); - - assert.verifySteps(['destroy']); - - // Wait nvd3 to fully render the graph. If ommited, may slow down following tests. - return concurrency.delay(0); - }); - - QUnit.test('graph dashboard widget can be destroyed when nv is partially loaded', async function (assert) { - // this test ensures that the JournalDashboardGraph widget doesn't crash - // when being destroyed before nv has been completely loaded - assert.expect(2); - - testUtils.mock.patch(basicFields.JournalDashboardGraph, { - destroy: function () { - assert.step('destroy'); - // nv is fully loaded only when nvd3.js has been loaded - // which happens sequentially after nv.d3.js - // we simulate this race condition with: - var offWindowResize = window.nv.utils.offWindowResize; - window.nv.utils.offWindowResize = undefined; - this._super.apply(this, arguments); - window.nv.utils.offWindowResize = offWindowResize; - }, - }); - - var kanban = await createView({ - View: KanbanView, - model: 'partner', - data: this.data, - arch: '' + - '' + - '' + - '
' + - '' + - '
' + - '
' + - '
', - domain: [['id', 'in', [1]]], - }); - - kanban.destroy(); - testUtils.mock.unpatch(basicFields.JournalDashboardGraph); - - assert.verifySteps(['destroy']); - - // Wait nvd3 to fully render the graph. If ommited, may slow down following tests. - return concurrency.delay(0); - }); - QUnit.test('rendering of a field with dashboard_graph widget in an updated kanban view (ungrouped)', async function (assert) { var done = assert.async(); @@ -2473,16 +2374,12 @@ QUnit.module('basic_fields', { domain: [['id', 'in', [1, 2]]], }).then(function (kanban) { concurrency.delay(0).then(function () { - return concurrency.delay(0); - }).then(function () { - assert.containsN(kanban, '.o_dashboard_graph svg', 2, "there should be two graph rendered"); + assert.containsN(kanban, '.o_dashboard_graph canvas', 2, "there should be two graph rendered"); return kanban.update({}); }).then(function () { return concurrency.delay(0); // one graph is re-rendered }).then(function () { - return concurrency.delay(0); // one graph is re-rendered - }).then(function () { - assert.containsN(kanban, '.o_dashboard_graph svg', 2, "there should be one graph rendered"); + assert.containsN(kanban, '.o_dashboard_graph canvas', 2, "there should be one graph rendered"); kanban.destroy(); done(); }); @@ -2509,14 +2406,10 @@ QUnit.module('basic_fields', { domain: [['id', 'in', [1, 2]]], }).then(function (kanban) { concurrency.delay(0).then(function () { - return concurrency.delay(0); - }).then(function () { - assert.containsN(kanban, '.o_dashboard_graph svg', 2, "there should be two graph rendered"); + assert.containsN(kanban, '.o_dashboard_graph canvas', 2, "there should be two graph rendered"); return kanban.update({groupBy: ['selection'], domain: [['int_field', '=', 10]]}); }).then(function () { - return concurrency.delay(0); - }).then(function () { - assert.containsOnce(kanban, '.o_dashboard_graph svg', "there should be one graph rendered"); + assert.containsOnce(kanban, '.o_dashboard_graph canvas', "there should be one graph rendered"); kanban.destroy(); done(); });