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 f47bfaeb5bb..eb5fd107081 100644
--- a/addons/web/static/src/js/views/graph/graph_controller.js
+++ b/addons/web/static/src/js/views/graph/graph_controller.js
@@ -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;
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 21614493e3c..e1411324c77 100644
--- a/addons/web/static/src/js/views/graph/graph_model.js
+++ b/addons/web/static/src/js/views/graph/graph_model.js
@@ -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,
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 cf6dba6e4c3..35e78690528 100644
--- a/addons/web/static/src/js/views/graph/graph_renderer.js
+++ b/addons/web/static/src/js/views/graph/graph_renderer.js
@@ -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
diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js
index 488424536e6..5ae120343c6 100644
--- a/addons/web/static/src/js/views/graph/graph_view.js
+++ b/addons/web/static/src/js/views/graph/graph_view.js
@@ -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__';
diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js
index 05d490927ea..a8baa45d8d2 100644
--- a/addons/web/static/tests/views/graph_tests.js
+++ b/addons/web/static/tests/views/graph_tests.js
@@ -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: '',
+ 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: '',
+ 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: '',
+ 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';
diff --git a/doc/reference/views.rst b/doc/reference/views.rst
index 65951b75f5b..c36327df7aa 100644
--- a/doc/reference/views.rst
+++ b/doc/reference/views.rst
@@ -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:
diff --git a/odoo/addons/base/rng/graph_view.rng b/odoo/addons/base/rng/graph_view.rng
index e0a8c191d0a..c0884f424a2 100644
--- a/odoo/addons/base/rng/graph_view.rng
+++ b/odoo/addons/base/rng/graph_view.rng
@@ -23,6 +23,7 @@
+