[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
This commit is contained in:
Géry Debongnie
2019-04-01 14:40:51 +00:00
committed by Mathieu Duckerts-Antoine
parent 2af74a5f53
commit 769806ad13
4 changed files with 112 additions and 308 deletions
@@ -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 {
+96 -119
View File
@@ -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('<svg>');
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 = $('<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
}
},
},
};
},
});
@@ -187,7 +187,6 @@
.o_dashboard_graph {
overflow: hidden;
margin: ($o-kanban-dashboard-vpadding * 2) (-$o-kanban-dashboard-hpadding);
}
}
@@ -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', {
'</templates></kanban>',
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: '<kanban class="o_kanban_test">' +
'<field name="graph_type"/>' +
'<templates><t t-name="kanban-box">' +
'<div>' +
'<field name="graph_data" t-att-graph_type="record.graph_type.raw_value" widget="dashboard_graph"/>' +
'</div>' +
'</t>' +
'</templates></kanban>',
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: '<kanban class="o_kanban_test">' +
'<field name="graph_type"/>' +
'<templates><t t-name="kanban-box">' +
'<div>' +
'<field name="graph_data" t-att-graph_type="record.graph_type.raw_value" widget="dashboard_graph"/>' +
'</div>' +
'</t>' +
'</templates></kanban>',
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();
});