[IMP] web: graph: drill down to records in bar/pie charts

After this commit, in the graph view, when the user clicks on parts
of the bar and pie charts, it opens the list view of the concerned
records (like what was already done in the pivot view).

This is the default behavior, but it can be deactivated by setting
the attribute 'disable_linking' to true in the view.

Task 2198461

closes odoo/odoo#47796

Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
Co-authored-by: Aaron Bohy <aab@odoo.com>
This commit is contained in:
Dhruv Patel
2020-03-31 19:15:10 +00:00
committed by Aaron Bohy
co-authored by Aaron Bohy
parent 2516c0a212
commit ae610d0e20
7 changed files with 231 additions and 1 deletions
@@ -23,6 +23,7 @@ class CarretDropdownMenu extends DropdownMenu {
var GraphController = AbstractController.extend({
custom_events: _.extend({}, AbstractController.prototype.custom_events, {
item_selected: '_onItemSelected',
open_view: '_onOpenView',
}),
/**
@@ -42,6 +43,9 @@ var GraphController = AbstractController.extend({
// button in the control panel owned by the graph view.
this.isEmbedded = params.isEmbedded;
this.withButtons = params.withButtons;
// views to use in the action triggered when the graph is clicked
this.views = params.views;
this.title = params.title;
// this parameter determines what is the list of fields
// that may be used within the groupby menu available when
@@ -296,6 +300,32 @@ var GraphController = AbstractController.extend({
this.measureMenu.update({ items: this.measures });
}
},
/**
* @private
* @param {OdooEvent} ev
* @param {Array[]} ev.data.domain
*/
_onOpenView(ev) {
ev.stopPropagation();
const state = this.model.get();
const context = Object.assign({}, state.context);
Object.keys(context).forEach(x => {
if (x === 'group_by' || x.startsWith('search_default_')) {
delete context[x];
}
});
this.do_action({
context: context,
domain: ev.data.domain,
name: this.title,
res_model: this.modelName,
target: 'current',
type: 'ir.actions.act_window',
view_mode: 'list',
views: this.views,
});
},
});
return GraphController;
@@ -244,6 +244,7 @@ return AbstractModel.extend({
self.chart.dataPoints.push({
resId: dataPt[self.chart.groupBy[0]] instanceof Array ? dataPt[self.chart.groupBy[0]][0] : -1,
count: count,
domain: dataPt.__domain,
value: value,
labels: labels,
originIndex: originIndex,
@@ -68,6 +68,7 @@ return AbstractRenderer.extend({
this.isEmbedded = params.isEmbedded || false;
this.title = params.title || '';
this.fields = params.fields || {};
this.disableLinking = params.disableLinking;
this.chart = null;
this.chartId = _.uniqueId('chart');
@@ -134,6 +135,7 @@ return AbstractRenderer.extend({
* @param {Object} tooltipModel see chartjs documentation
*/
_customTooltip: function (tooltipModel) {
this.$el.css({ cursor: 'default' });
if (this.$tooltip) {
this.$tooltip.remove();
}
@@ -144,6 +146,10 @@ return AbstractRenderer.extend({
return;
}
if (this._isRedirectionEnabled()) {
this.$el.css({ cursor: 'pointer' });
}
const chartArea = this.chart.chartArea;
const chartAreaLeft = chartArea.left;
const chartAreaRight = chartArea.right;
@@ -578,6 +584,17 @@ return AbstractRenderer.extend({
}
return tooltipOptions;
},
/**
* Returns true iff the current graph can be clicked on to redirect to the
* list of records.
*
* @private
* @returns {boolean}
*/
_isRedirectionEnabled: function () {
return !this.disableLinking &&
(this.state.mode === 'bar' || this.state.mode === 'pie');
},
/**
* Return the first index of the array list where label can be found
* or -1.
@@ -642,9 +659,11 @@ return AbstractRenderer.extend({
var newDataset = function (datasetLabel, originIndex) {
var data = new Array(self._getDatasetDataLength(originIndex, labels.length)).fill(0);
const domain = new Array(self._getDatasetDataLength(originIndex, labels.length)).fill([]);
return {
label: datasetLabel,
data: data,
domain: domain,
originIndex: originIndex,
};
};
@@ -658,6 +677,7 @@ return AbstractRenderer.extend({
}
var labelIndex = dataPt.labelIndex;
acc[datasetLabel].data[labelIndex] = dataPt.value;
acc[datasetLabel].domain[labelIndex] = dataPt.domain;
return acc;
},
{}
@@ -682,13 +702,17 @@ return AbstractRenderer.extend({
* @returns {Object} the chart options used for the current mode
*/
_prepareOptions: function (datasetsCount) {
return {
const options = {
maintainAspectRatio: false,
scales: this._getScaleOptions(),
legend: this._getLegendOptions(datasetsCount),
tooltips: this._getTooltipOptions(),
elements: this._getElementOptions(),
};
if (this._isRedirectionEnabled()) {
options.onClick = this._onGraphClicked.bind(this);
}
return options;
},
/**
* Determine how to relabel a label according to a given origin.
@@ -987,6 +1011,23 @@ return AbstractRenderer.extend({
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} ev
*/
_onGraphClicked: function (ev) {
const activeElement = this.chart.getElementAtEvent(ev);
if (activeElement.length === 0) {
return;
}
const domain = this.chart.data.datasets[activeElement[0]._datasetIndex].domain;
if (!domain) {
return; // empty dataset
}
this.trigger_up('open_view', {
domain: domain[activeElement[0]._index],
});
},
/**
* 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
@@ -115,8 +115,23 @@ var GraphView = AbstractView.extend({
this.controllerParams.withButtons = params.withButtons !== false;
this.controllerParams.measures = [...sortedMeasures, countMeasure];
this.controllerParams.groupableFields = groupableFields;
this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled");
// retrieve form and list view ids from the action to open those views
// when the graph is clicked
function _findView(views, viewType) {
const view = views.find(view => {
return view.type === viewType;
});
return [view ? view.viewID : false, viewType];
}
this.controllerParams.views = [
_findView(params.actionViews, 'list'),
_findView(params.actionViews, 'form'),
];
this.rendererParams.fields = this.fields;
this.rendererParams.title = this.arch.attrs.title; // TODO: use attrs.string instead
this.rendererParams.disableLinking = !!JSON.parse(this.arch.attrs.disable_linking || '0');
this.loadParams.mode = this.arch.attrs.type || 'bar';
this.loadParams.measure = measure || '__count__';
@@ -979,6 +979,146 @@ QUnit.module('Views', {
graph.destroy();
});
QUnit.test('clicking on bar and pie charts triggers a do_action', async function (assert) {
assert.expect(5);
const graph = await createView({
View: GraphView,
model: "foo",
data: this.data,
arch: '<graph string="Foo Analysis"><field name="bar"/></graph>',
intercepts: {
do_action: function (ev) {
assert.deepEqual(ev.data.action, {
context: {},
domain: [["bar", "=", true]],
name: "Foo Analysis",
res_model: "foo",
target: 'current',
type: 'ir.actions.act_window',
view_mode: 'list',
views: [[false, 'list'], [false, 'form']],
}, "should trigger do_action with correct action parameter");
}
},
});
await testUtils.nextTick(); // wait for the graph to be rendered
// bar mode
assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode");
assert.checkDatasets(graph, ['domain'], {
domain: [[["bar", "=", true]], [["bar", "=", false]]],
});
let myChart = graph.renderer.chart;
let meta = myChart.getDatasetMeta(0);
let rectangle = myChart.canvas.getBoundingClientRect();
let point = meta.data[0].getCenterPoint();
await testUtils.dom.triggerEvent(myChart.canvas, 'click', {
pageX: rectangle.left + point.x,
pageY: rectangle.top + point.y
});
// pie mode
await testUtils.dom.click(graph.$('.o_graph_button[data-mode=pie]'));
assert.strictEqual(graph.renderer.state.mode, "pie", "should be in pie chart mode");
myChart = graph.renderer.chart;
meta = myChart.getDatasetMeta(0);
rectangle = myChart.canvas.getBoundingClientRect();
point = meta.data[0].getCenterPoint();
await testUtils.dom.triggerEvent(myChart.canvas, 'click', {
pageX: rectangle.left + point.x,
pageY: rectangle.top + point.y
});
graph.destroy();
});
QUnit.test('clicking charts trigger a do_action with correct views', async function (assert) {
assert.expect(3);
const graph = await createView({
View: GraphView,
model: "foo",
data: this.data,
arch: '<graph string="Foo Analysis"><field name="bar"/></graph>',
intercepts: {
do_action: function (ev) {
assert.deepEqual(ev.data.action, {
context: {},
domain: [["bar", "=", true]],
name: "Foo Analysis",
res_model: "foo",
target: 'current',
type: 'ir.actions.act_window',
view_mode: 'list',
views: [[364, 'list'], [29, 'form']],
}, "should trigger do_action with correct action parameter");
}
},
viewOptions: {
actionViews: [{
type: 'list',
viewID: 364,
}, {
type: 'form',
viewID: 29,
}],
},
});
await testUtils.nextTick(); // wait for the graph to be rendered
assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode");
assert.checkDatasets(graph, ['domain'], {
domain: [[["bar", "=", true]], [["bar", "=", false]]],
});
let myChart = graph.renderer.chart;
let meta = myChart.getDatasetMeta(0);
let rectangle = myChart.canvas.getBoundingClientRect();
let point = meta.data[0].getCenterPoint();
await testUtils.dom.triggerEvent(myChart.canvas, 'click', {
pageX: rectangle.left + point.x,
pageY: rectangle.top + point.y
});
graph.destroy();
});
QUnit.test('graph view with attribute disable_linking="True"', async function (assert) {
assert.expect(2);
const graph = await createView({
View: GraphView,
model: "foo",
data: this.data,
arch: '<graph disable_linking="1"><field name="bar"/></graph>',
intercepts: {
do_action: function () {
throw new Error('Should not perform a do_action');
},
},
});
await testUtils.nextTick(); // wait for the graph to be rendered
assert.strictEqual(graph.renderer.state.mode, "bar", "should be in bar chart mode");
assert.checkDatasets(graph, ['domain'], {
domain: [[["bar", "=", true]], [["bar", "=", false]]],
});
let myChart = graph.renderer.chart;
let meta = myChart.getDatasetMeta(0);
let rectangle = myChart.canvas.getBoundingClientRect();
let point = meta.data[0].getCenterPoint();
await testUtils.dom.triggerEvent(myChart.canvas, 'click', {
pageX: rectangle.left + point.x,
pageY: rectangle.top + point.y
});
graph.destroy();
});
QUnit.module('GraphView: comparison mode', {
beforeEach: async function () {
this.data.foo.records[0].date = '2016-12-15';
+2
View File
@@ -1072,6 +1072,8 @@ attributes:
``stacked``
only used for ``bar`` charts. If present and set to ``True``, stacks bars
within a group
``disable_linking``
set to ``True`` to prevent from redirecting clicks on graph to list view
The only allowed element within a graph view is ``field`` which can have the
following attributes:
+1
View File
@@ -23,6 +23,7 @@
<rng:optional><rng:attribute name="stacked"/></rng:optional>
<rng:optional><rng:attribute name="orientation"/></rng:optional>
<rng:optional><rng:attribute name="interval"/></rng:optional>
<rng:optional><rng:attribute name="disable_linking"/></rng:optional>
<rng:zeroOrMore>
<rng:ref name="field"/>
</rng:zeroOrMore>