[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:
committed by
Aaron Bohy
co-authored by
Aaron Bohy
parent
2516c0a212
commit
ae610d0e20
@@ -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';
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user