summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views/graph
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/views/graph
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views/graph')
-rw-r--r--addons/web/static/src/js/views/graph/graph_controller.js356
-rw-r--r--addons/web/static/src/js/views/graph/graph_model.js322
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js1099
-rw-r--r--addons/web/static/src/js/views/graph/graph_view.js162
4 files changed, 1939 insertions, 0 deletions
diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js
new file mode 100644
index 00000000..6cb2b899
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_controller.js
@@ -0,0 +1,356 @@
+odoo.define('web.GraphController', function (require) {
+"use strict";
+
+/*---------------------------------------------------------
+ * Odoo Graph view
+ *---------------------------------------------------------*/
+
+const AbstractController = require('web.AbstractController');
+const { ComponentWrapper } = require('web.OwlCompatibility');
+const DropdownMenu = require('web.DropdownMenu');
+const { DEFAULT_INTERVAL, INTERVAL_OPTIONS } = require('web.searchUtils');
+const { qweb } = require('web.core');
+const { _t } = require('web.core');
+
+class CarretDropdownMenu extends DropdownMenu {
+ /**
+ * @override
+ */
+ get displayCaret() {
+ return true;
+ }
+}
+
+var GraphController = AbstractController.extend({
+ custom_events: _.extend({}, AbstractController.prototype.custom_events, {
+ item_selected: '_onItemSelected',
+ open_view: '_onOpenView',
+ }),
+
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {GraphModel} model
+ * @param {GraphRenderer} renderer
+ * @param {Object} params
+ * @param {string[]} params.measures
+ * @param {boolean} params.isEmbedded
+ * @param {string[]} params.groupableFields,
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.measures = params.measures;
+ // this parameter condition the appearance of a 'Group By'
+ // 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
+ // the view is embedded
+ this.groupableFields = params.groupableFields;
+ this.buttonDropdownPromises = [];
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$el.addClass('o_graph_controller');
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @todo check if this can be removed (mostly duplicate with
+ * AbstractController method)
+ */
+ destroy: function () {
+ if (this.$buttons) {
+ // remove jquery's tooltip() handlers
+ this.$buttons.find('button').off().tooltip('dispose');
+ }
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns the current mode, measure and groupbys, so we can restore the
+ * view when we save the current state in the search view, or when we add it
+ * to the dashboard.
+ *
+ * @override
+ * @returns {Object}
+ */
+ getOwnedQueryParams: function () {
+ var state = this.model.get();
+ return {
+ context: {
+ graph_measure: state.measure,
+ graph_mode: state.mode,
+ graph_groupbys: state.groupBy,
+ }
+ };
+ },
+ /**
+ * @override
+ */
+ reload: async function () {
+ const promises = [this._super(...arguments)];
+ if (this.withButtons) {
+ const state = this.model.get();
+ this.measures.forEach(m => m.isActive = m.fieldName === state.measure);
+ promises.push(this.measureMenu.update({ items: this.measures }));
+ }
+ return Promise.all(promises);
+ },
+ /**
+ * Render the buttons according to the GraphView.buttons and
+ * add listeners on it.
+ * Set this.$buttons with the produced jQuery element
+ *
+ * @param {jQuery} [$node] a jQuery node where the rendered buttons should
+ * be inserted $node may be undefined, in which case the GraphView does
+ * nothing
+ */
+ renderButtons: function ($node) {
+ this.$buttons = $(qweb.render('GraphView.buttons'));
+ this.$buttons.find('button').tooltip();
+ this.$buttons.click(ev => this._onButtonClick(ev));
+
+ if (this.withButtons) {
+ const state = this.model.get();
+ const fragment = document.createDocumentFragment();
+ // Instantiate and append MeasureMenu
+ this.measures.forEach(m => m.isActive = m.fieldName === state.measure);
+ this.measureMenu = new ComponentWrapper(this, CarretDropdownMenu, {
+ title: _t("Measures"),
+ items: this.measures,
+ });
+ this.buttonDropdownPromises = [this.measureMenu.mount(fragment)];
+ if ($node) {
+ if (this.isEmbedded) {
+ // Instantiate and append GroupBy menu
+ this.groupByMenu = new ComponentWrapper(this, CarretDropdownMenu, {
+ title: _t("Group By"),
+ icon: 'fa fa-bars',
+ items: this._getGroupBys(state.groupBy),
+ });
+ this.buttonDropdownPromises.push(this.groupByMenu.mount(fragment));
+ }
+ this.$buttons.appendTo($node);
+ }
+ }
+ },
+ /**
+ * Makes sure that the buttons in the control panel matches the current
+ * state (so, correct active buttons and stuff like that).
+ *
+ * @override
+ */
+ updateButtons: function () {
+ if (!this.$buttons) {
+ return;
+ }
+ var state = this.model.get();
+ this.$buttons.find('.o_graph_button').removeClass('active');
+ this.$buttons
+ .find('.o_graph_button[data-mode="' + state.mode + '"]')
+ .addClass('active');
+ this.$buttons
+ .find('.o_graph_button[data-mode="stack"]')
+ .data('stacked', state.stacked)
+ .toggleClass('active', state.stacked)
+ .toggleClass('o_hidden', state.mode !== 'bar');
+ this.$buttons
+ .find('.o_graph_button[data-order]')
+ .toggleClass('o_hidden', state.mode === 'pie' || !!Object.keys(state.timeRanges).length)
+ .filter('.o_graph_button[data-order="' + state.orderBy + '"]')
+ .toggleClass('active', !!state.orderBy);
+
+ if (this.withButtons) {
+ this._attachDropdownComponents();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Attaches the different dropdown components to the buttons container.
+ *
+ * @returns {Promise}
+ */
+ async _attachDropdownComponents() {
+ await Promise.all(this.buttonDropdownPromises);
+ const actionsContainer = this.$buttons[0];
+ // Attach "measures" button
+ actionsContainer.appendChild(this.measureMenu.el);
+ this.measureMenu.el.classList.add('o_graph_measures_list');
+ if (this.isEmbedded) {
+ // Attach "groupby" button
+ actionsContainer.appendChild(this.groupByMenu.el);
+ this.groupByMenu.el.classList.add('o_group_by_menu');
+ }
+ // Update button classes accordingly to the current mode
+ const buttons = actionsContainer.querySelectorAll('.o_dropdown_toggler_btn');
+ for (const button of buttons) {
+ button.classList.remove('o_dropdown_toggler_btn', 'btn-secondary');
+ if (this.isEmbedded) {
+ button.classList.add('btn-outline-secondary');
+ } else {
+ button.classList.add('btn-primary');
+ button.tabIndex = 0;
+ }
+ }
+ },
+
+ /**
+ * Returns the items used by the Group By menu in embedded mode.
+ *
+ * @private
+ * @param {string[]} activeGroupBys
+ * @returns {Object[]}
+ */
+ _getGroupBys(activeGroupBys) {
+ const normalizedGroupBys = this._normalizeActiveGroupBys(activeGroupBys);
+ const groupBys = Object.keys(this.groupableFields).map(fieldName => {
+ const field = this.groupableFields[fieldName];
+ const groupByActivity = normalizedGroupBys.filter(gb => gb.fieldName === fieldName);
+ const groupBy = {
+ id: fieldName,
+ isActive: Boolean(groupByActivity.length),
+ description: field.string,
+ itemType: 'groupBy',
+ };
+ if (['date', 'datetime'].includes(field.type)) {
+ groupBy.hasOptions = true;
+ const activeOptionIds = groupByActivity.map(gb => gb.interval);
+ groupBy.options = Object.values(INTERVAL_OPTIONS).map(o => {
+ return Object.assign({}, o, { isActive: activeOptionIds.includes(o.id) });
+ });
+ }
+ return groupBy;
+ }).sort((gb1, gb2) => {
+ return gb1.description.localeCompare(gb2.description);
+ });
+ return groupBys;
+ },
+
+ /**
+ * This method puts the active groupBys in a convenient form.
+ *
+ * @private
+ * @param {string[]} activeGroupBys
+ * @returns {Object[]} normalizedGroupBys
+ */
+ _normalizeActiveGroupBys(activeGroupBys) {
+ return activeGroupBys.map(groupBy => {
+ const fieldName = groupBy.split(':')[0];
+ const field = this.groupableFields[fieldName];
+ const normalizedGroupBy = { fieldName };
+ if (['date', 'datetime'].includes(field.type)) {
+ normalizedGroupBy.interval = groupBy.split(':')[1] || DEFAULT_INTERVAL;
+ }
+ return normalizedGroupBy;
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Do what need to be done when a button from the control panel is clicked.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onButtonClick: function (ev) {
+ var $target = $(ev.target);
+ if ($target.hasClass('o_graph_button')) {
+ if (_.contains(['bar','line', 'pie'], $target.data('mode'))) {
+ this.update({ mode: $target.data('mode') });
+ } else if ($target.data('mode') === 'stack') {
+ this.update({ stacked: !$target.data('stacked') });
+ } else if (['asc', 'desc'].includes($target.data('order'))) {
+ const order = $target.data('order');
+ const state = this.model.get();
+ this.update({ orderBy: state.orderBy === order ? false : order });
+ }
+ }
+ },
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onItemSelected(ev) {
+ const item = ev.data.item;
+ if (this.isEmbedded && item.itemType === 'groupBy') {
+ const fieldName = item.id;
+ const optionId = ev.data.option && ev.data.option.id;
+ const activeGroupBys = this.model.get().groupBy;
+ if (optionId) {
+ const normalizedGroupBys = this._normalizeActiveGroupBys(activeGroupBys);
+ const index = normalizedGroupBys.findIndex(ngb =>
+ ngb.fieldName === fieldName && ngb.interval === optionId);
+ if (index === -1) {
+ activeGroupBys.push(fieldName + ':' + optionId);
+ } else {
+ activeGroupBys.splice(index, 1);
+ }
+ } else {
+ const groupByFieldNames = activeGroupBys.map(gb => gb.split(':')[0]);
+ const indexOfGroupby = groupByFieldNames.indexOf(fieldName);
+ if (indexOfGroupby === -1) {
+ activeGroupBys.push(fieldName);
+ } else {
+ activeGroupBys.splice(indexOfGroupby, 1);
+ }
+ }
+ this.update({ groupBy: activeGroupBys });
+ this.groupByMenu.update({
+ items: this._getGroupBys(activeGroupBys),
+ });
+ } else if (item.itemType === 'measure') {
+ this.update({ measure: item.fieldName });
+ this.measures.forEach(m => m.isActive = m.fieldName === item.fieldName);
+ 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
new file mode 100644
index 00000000..b1bcddb4
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_model.js
@@ -0,0 +1,322 @@
+odoo.define('web.GraphModel', function (require) {
+"use strict";
+
+var core = require('web.core');
+const { DEFAULT_INTERVAL, rankInterval } = require('web.searchUtils');
+
+var _t = core._t;
+
+/**
+ * The graph model is responsible for fetching and processing data from the
+ * server. It basically just do a(some) read_group(s) and format/normalize data.
+ */
+var AbstractModel = require('web.AbstractModel');
+
+return AbstractModel.extend({
+ /**
+ * @override
+ * @param {Widget} parent
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.chart = null;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ *
+ * We defend against outside modifications by extending the chart data. It
+ * may be overkill.
+ *
+ * @override
+ * @returns {Object}
+ */
+ __get: function () {
+ return Object.assign({ isSample: this.isSampleModel }, this.chart);
+ },
+ /**
+ * Initial loading.
+ *
+ * @todo All the work to fall back on the graph_groupbys keys in the context
+ * should be done by the graphView I think.
+ *
+ * @param {Object} params
+ * @param {Object} params.context
+ * @param {Object} params.fields
+ * @param {string[]} params.domain
+ * @param {string[]} params.groupBys a list of valid field names
+ * @param {string[]} params.groupedBy a list of valid field names
+ * @param {boolean} params.stacked
+ * @param {string} params.measure a valid field name
+ * @param {'pie'|'bar'|'line'} params.mode
+ * @param {string} params.modelName
+ * @param {Object} params.timeRanges
+ * @returns {Promise} The promise does not return a handle, we don't need
+ * to keep track of various entities.
+ */
+ __load: function (params) {
+ var groupBys = params.context.graph_groupbys || params.groupBys;
+ this.initialGroupBys = groupBys;
+ this.fields = params.fields;
+ this.modelName = params.modelName;
+ this.chart = Object.assign({
+ context: params.context,
+ dataPoints: [],
+ domain: params.domain,
+ groupBy: params.groupedBy.length ? params.groupedBy : groupBys,
+ measure: params.context.graph_measure || params.measure,
+ mode: params.context.graph_mode || params.mode,
+ origins: [],
+ stacked: params.stacked,
+ timeRanges: params.timeRanges,
+ orderBy: params.orderBy
+ });
+
+ this._computeDerivedParams();
+
+ return this._loadGraph();
+ },
+ /**
+ * Reload data. It is similar to the load function. Note that we ignore the
+ * handle parameter, we always expect our data to be in this.chart object.
+ *
+ * @todo This method takes 'groupBy' and load method takes 'groupedBy'. This
+ * is insane.
+ *
+ * @param {any} handle ignored!
+ * @param {Object} params
+ * @param {boolean} [params.stacked]
+ * @param {Object} [params.context]
+ * @param {string[]} [params.domain]
+ * @param {string[]} [params.groupBy]
+ * @param {string} [params.measure] a valid field name
+ * @param {string} [params.mode] one of 'bar', 'pie', 'line'
+ * @param {Object} [params.timeRanges]
+ * @returns {Promise}
+ */
+ __reload: function (handle, params) {
+ if ('context' in params) {
+ this.chart.context = params.context;
+ this.chart.groupBy = params.context.graph_groupbys || this.chart.groupBy;
+ this.chart.measure = params.context.graph_measure || this.chart.measure;
+ this.chart.mode = params.context.graph_mode || this.chart.mode;
+ }
+ if ('domain' in params) {
+ this.chart.domain = params.domain;
+ }
+ if ('groupBy' in params) {
+ this.chart.groupBy = params.groupBy.length ? params.groupBy : this.initialGroupBys;
+ }
+ if ('measure' in params) {
+ this.chart.measure = params.measure;
+ }
+ if ('timeRanges' in params) {
+ this.chart.timeRanges = params.timeRanges;
+ }
+
+ this._computeDerivedParams();
+
+ if ('mode' in params) {
+ this.chart.mode = params.mode;
+ return Promise.resolve();
+ }
+ if ('stacked' in params) {
+ this.chart.stacked = params.stacked;
+ return Promise.resolve();
+ }
+ if ('orderBy' in params) {
+ this.chart.orderBy = params.orderBy;
+ return Promise.resolve();
+ }
+ return this._loadGraph();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Compute this.chart.processedGroupBy, this.chart.domains, this.chart.origins,
+ * and this.chart.comparisonFieldIndex.
+ * Those parameters are determined by this.chart.timeRanges, this.chart.groupBy, and this.chart.domain.
+ *
+ * @private
+ */
+ _computeDerivedParams: function () {
+ this.chart.processedGroupBy = this._processGroupBy(this.chart.groupBy);
+
+ const { range, rangeDescription, comparisonRange, comparisonRangeDescription, fieldName } = this.chart.timeRanges;
+ if (range) {
+ this.chart.domains = [
+ this.chart.domain.concat(range),
+ this.chart.domain.concat(comparisonRange),
+ ];
+ this.chart.origins = [rangeDescription, comparisonRangeDescription];
+ const groupBys = this.chart.processedGroupBy.map(function (gb) {
+ return gb.split(":")[0];
+ });
+ this.chart.comparisonFieldIndex = groupBys.indexOf(fieldName);
+ } else {
+ this.chart.domains = [this.chart.domain];
+ this.chart.origins = [""];
+ this.chart.comparisonFieldIndex = -1;
+ }
+ },
+ /**
+ * @override
+ */
+ _isEmpty() {
+ return this.chart.dataPoints.length === 0;
+ },
+ /**
+ * Fetch and process graph data. It is basically a(some) read_group(s)
+ * with correct fields for each domain. We have to do some light processing
+ * to separate date groups in the field list, because they can be defined
+ * with an aggregation function, such as my_date:week.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _loadGraph: function () {
+ var self = this;
+ this.chart.dataPoints = [];
+ var groupBy = this.chart.processedGroupBy;
+ var fields = _.map(groupBy, function (groupBy) {
+ return groupBy.split(':')[0];
+ });
+
+ if (this.chart.measure !== '__count__') {
+ if (this.fields[this.chart.measure].type === 'many2one') {
+ fields = fields.concat(this.chart.measure + ":count_distinct");
+ }
+ else {
+ fields = fields.concat(this.chart.measure);
+ }
+ }
+
+ var context = _.extend({fill_temporal: true}, this.chart.context);
+
+ var proms = [];
+ this.chart.domains.forEach(function (domain, originIndex) {
+ proms.push(self._rpc({
+ model: self.modelName,
+ method: 'read_group',
+ context: context,
+ domain: domain,
+ fields: fields,
+ groupBy: groupBy,
+ lazy: false,
+ }).then(self._processData.bind(self, originIndex)));
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * Since read_group is insane and returns its result on different keys
+ * depending of some input, we have to normalize the result.
+ * Each group coming from the read_group produces a dataPoint
+ *
+ * @todo This is not good for race conditions. The processing should get
+ * the object this.chart in argument, or an array or something. We want to
+ * avoid writing on a this.chart object modified by a subsequent read_group
+ *
+ * @private
+ * @param {number} originIndex
+ * @param {any} rawData result from the read_group
+ */
+ _processData: function (originIndex, rawData) {
+ var self = this;
+ var isCount = this.chart.measure === '__count__';
+ var labels;
+
+ function getLabels (dataPt) {
+ return self.chart.processedGroupBy.map(function (field) {
+ return self._sanitizeValue(dataPt[field], field.split(":")[0]);
+ });
+ }
+ rawData.forEach(function (dataPt){
+ labels = getLabels(dataPt);
+ var count = dataPt.__count || dataPt[self.chart.processedGroupBy[0]+'_count'] || 0;
+ var value = isCount ? count : dataPt[self.chart.measure];
+ if (value instanceof Array) {
+ // when a many2one field is used as a measure AND as a grouped
+ // field, bad things happen. The server will only return the
+ // grouped value and will not aggregate it. Since there is a
+ // name clash, we are then in the situation where this value is
+ // an array. Fortunately, if we group by a field, then we can
+ // say for certain that the group contains exactly one distinct
+ // value for that field.
+ value = 1;
+ }
+ 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,
+ });
+ });
+ },
+ /**
+ * Process the groupBy parameter in order to keep only the finer interval option for
+ * elements based on date/datetime field (e.g. 'date:year'). This means that
+ * 'week' is prefered to 'month'. The field stays at the place of its first occurence.
+ * For instance,
+ * ['foo', 'date:month', 'bar', 'date:week'] becomes ['foo', 'date:week', 'bar'].
+ *
+ * @private
+ * @param {string[]} groupBy
+ * @returns {string[]}
+ */
+ _processGroupBy: function(groupBy) {
+ const groupBysMap = new Map();
+ for (const gb of groupBy) {
+ let [fieldName, interval] = gb.split(':');
+ const field = this.fields[fieldName];
+ if (['date', 'datetime'].includes(field.type)) {
+ interval = interval || DEFAULT_INTERVAL;
+ }
+ if (groupBysMap.has(fieldName)) {
+ const registeredInterval = groupBysMap.get(fieldName);
+ if (rankInterval(registeredInterval) < rankInterval(interval)) {
+ groupBysMap.set(fieldName, interval);
+ }
+ } else {
+ groupBysMap.set(fieldName, interval);
+ }
+ }
+ return [...groupBysMap].map(([fieldName, interval]) => {
+ if (interval) {
+ return `${fieldName}:${interval}`;
+ }
+ return fieldName;
+ });
+ },
+ /**
+ * Helper function (for _processData), turns various values in a usable
+ * string form, that we can display in the interface.
+ *
+ * @private
+ * @param {any} value value for the field fieldName received by the read_group rpc
+ * @param {string} fieldName
+ * @returns {string}
+ */
+ _sanitizeValue: function (value, fieldName) {
+ if (value === false && this.fields[fieldName].type !== 'boolean') {
+ return _t("Undefined");
+ }
+ if (value instanceof Array) {
+ return value[1];
+ }
+ if (fieldName && (this.fields[fieldName].type === 'selection')) {
+ var selected = _.where(this.fields[fieldName].selection, {0: value})[0];
+ return selected ? selected[1] : value;
+ }
+ return value;
+ },
+});
+
+});
diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js
new file mode 100644
index 00000000..118245f9
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_renderer.js
@@ -0,0 +1,1099 @@
+odoo.define('web.GraphRenderer', function (require) {
+"use strict";
+
+/**
+ * The graph renderer turns the data from the graph model into a nice looking
+ * canvas chart. This code uses the Chart.js library.
+ */
+
+var AbstractRenderer = require('web.AbstractRenderer');
+var config = require('web.config');
+var core = require('web.core');
+var dataComparisonUtils = require('web.dataComparisonUtils');
+var fieldUtils = require('web.field_utils');
+
+var _t = core._t;
+var DateClasses = dataComparisonUtils.DateClasses;
+var qweb = core.qweb;
+
+var CHART_TYPES = ['pie', 'bar', 'line'];
+
+var COLORS = ["#1f77b4", "#ff7f0e", "#aec7e8", "#ffbb78", "#2ca02c", "#98df8a", "#d62728",
+ "#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2",
+ "#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"];
+var COLOR_NB = COLORS.length;
+
+function hexToRGBA(hex, opacity) {
+ var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ var rgb = result.slice(1, 4).map(function (n) {
+ return parseInt(n, 16);
+ }).join(',');
+ return 'rgba(' + rgb + ',' + opacity + ')';
+}
+
+// used to format values in tooltips and yAxes.
+var FORMAT_OPTIONS = {
+ // allow to decide if utils.human_number should be used
+ humanReadable: function (value) {
+ return Math.abs(value) >= 1000;
+ },
+ // with the choices below, 1236 is represented by 1.24k
+ minDigits: 1,
+ decimals: 2,
+ // avoid comma separators for thousands in numbers when human_number is used
+ formatterCallback: function (str) {
+ return str;
+ },
+};
+
+var NO_DATA = [_t('No data')];
+NO_DATA.isNoData = true;
+
+// hide top legend when too many items for device size
+var MAX_LEGEND_LENGTH = 4 * (Math.max(1, config.device.size_class));
+
+return AbstractRenderer.extend({
+ className: "o_graph_renderer",
+ sampleDataTargets: ['.o_graph_canvas_container'],
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Object} state
+ * @param {Object} params
+ * @param {boolean} [params.isEmbedded]
+ * @param {Object} [params.fields]
+ * @param {string} [params.title]
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+ this.isEmbedded = params.isEmbedded || false;
+ this.title = params.title || '';
+ this.fields = params.fields || {};
+ this.disableLinking = params.disableLinking;
+
+ this.chart = null;
+ this.chartId = _.uniqueId('chart');
+ this.$legendTooltip = null;
+ this.$tooltip = null;
+ },
+ /**
+ * Chart.js does not need the canvas to be in dom in order
+ * to be able to work well. We could avoid the calls to on_attach_callback
+ * and on_detach_callback.
+ *
+ * @override
+ */
+ on_attach_callback: function () {
+ this._super.apply(this, arguments);
+ this.isInDOM = true;
+ this._render();
+ },
+ /**
+ * @override
+ */
+ on_detach_callback: function () {
+ this._super.apply(this, arguments);
+ this.isInDOM = false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * This function aims to remove a suitable number of lines from the tooltip in order to make it reasonably visible.
+ * A message indicating the number of lines is added if necessary.
+ *
+ * @private
+ * @param {Number} maxTooltipHeight this the max height in pixels of the tooltip
+ */
+ _adjustTooltipHeight: function (maxTooltipHeight) {
+ var sizeOneLine = this.$tooltip.find('tbody tr')[0].clientHeight;
+ var tbodySize = this.$tooltip.find('tbody')[0].clientHeight;
+ var toKeep = Math.floor((maxTooltipHeight - (this.$tooltip[0].clientHeight - tbodySize)) / sizeOneLine) - 1;
+ var $lines = this.$tooltip.find('tbody tr');
+ var toRemove = $lines.length - toKeep;
+ if (toRemove > 0) {
+ $lines.slice(toKeep).remove();
+ var tr = document.createElement('tr');
+ var td = document.createElement('td');
+ tr.classList.add('o_show_more');
+ td.innerHTML = _t("...");
+ tr.appendChild(td);
+ this.$tooltip.find('tbody').append(tr);
+ }
+ },
+ /**
+ * This function creates a custom HTML tooltip.
+ *
+ * @private
+ * @param {Object} tooltipModel see chartjs documentation
+ */
+ _customTooltip: function (tooltipModel) {
+ this.$el.css({ cursor: 'default' });
+ if (this.$tooltip) {
+ this.$tooltip.remove();
+ }
+ if (tooltipModel.opacity === 0) {
+ return;
+ }
+ if (tooltipModel.dataPoints.length === 0) {
+ return;
+ }
+
+ if (this._isRedirectionEnabled()) {
+ this.$el.css({ cursor: 'pointer' });
+ }
+
+ const chartArea = this.chart.chartArea;
+ const chartAreaLeft = chartArea.left;
+ const chartAreaRight = chartArea.right;
+ const chartAreaTop = chartArea.top;
+ const rendererTop = this.$el[0].getBoundingClientRect().top;
+
+ const maxTooltipLabelWidth = Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px';
+
+ const tooltipItems = this._getTooltipItems(tooltipModel);
+
+ this.$tooltip = $(qweb.render('GraphView.CustomTooltip', {
+ measure: this.fields[this.state.measure].string,
+ tooltipItems: tooltipItems,
+ maxWidth: maxTooltipLabelWidth,
+ })).css({top: '2px', left: '2px'});
+ const $container = this.$el.find('.o_graph_canvas_container');
+ $container.append(this.$tooltip);
+
+ let top;
+ const tooltipHeight = this.$tooltip[0].clientHeight;
+ const minTopAllowed = Math.floor(chartAreaTop);
+ const maxTopAllowed = Math.floor(window.innerHeight - rendererTop - tooltipHeight) - 2;
+ const y = Math.floor(tooltipModel.y);
+ if (minTopAllowed <= maxTopAllowed) {
+ // Here we know that the full tooltip can fit in the screen.
+ // We put it in the position where Chart.js would put it
+ // if two conditions are respected:
+ // 1: the tooltip is not cut (because we know it is possible to not cut it)
+ // 2: the tooltip does not hide the legend.
+ // If it is not possible to use the Chart.js proposition (y)
+ // we use the best approximated value.
+ if (y <= maxTopAllowed) {
+ if (y >= minTopAllowed) {
+ top = y;
+ } else {
+ top = minTopAllowed;
+ }
+ } else {
+ top = maxTopAllowed;
+ }
+ } else {
+ // Here we know that we cannot satisfy condition 1 above,
+ // so we position the tooltip at the minimal position and
+ // cut it the minimum possible.
+ top = minTopAllowed;
+ const maxTooltipHeight = window.innerHeight - (rendererTop + chartAreaTop) -2;
+ this._adjustTooltipHeight(maxTooltipHeight);
+ }
+ this.$tooltip[0].style.top = Math.floor(top) + 'px';
+
+ this._fixTooltipLeftPosition(this.$tooltip[0], tooltipModel.x);
+ },
+ /**
+ * Filter out some dataPoints because they would lead to bad graphics.
+ * The filtering is done with respect to the graph view mode.
+ * Note that the method does not alter this.state.dataPoints, since we
+ * want to be able to change of mode without fetching data again:
+ * we simply present the same data in a different way.
+ *
+ * @private
+ * @returns {Object[]}
+ */
+ _filterDataPoints: function () {
+ var dataPoints = [];
+ if (_.contains(['bar', 'pie'], this.state.mode)) {
+ dataPoints = this.state.dataPoints.filter(function (dataPt) {
+ return dataPt.count > 0;
+ });
+ } else if (this.state.mode === 'line') {
+ var counts = 0;
+ this.state.dataPoints.forEach(function (dataPt) {
+ if (dataPt.labels[0] !== _t("Undefined")) {
+ dataPoints.push(dataPt);
+ }
+ counts += dataPt.count;
+ });
+ // data points with zero count might have been created on purpose
+ // we only remove them if there are no data point with positive count
+ if (counts === 0) {
+ dataPoints = [];
+ }
+ }
+ return dataPoints;
+ },
+ /**
+ * Sets best left position of a tooltip approaching the proposal x
+ *
+ * @private
+ * @param {DOMElement} tooltip
+ * @param {number} x, left offset proposed
+ */
+ _fixTooltipLeftPosition: function (tooltip, x) {
+ let left;
+ const tooltipWidth = tooltip.clientWidth;
+ const minLeftAllowed = Math.floor(this.chart.chartArea.left + 2);
+ const maxLeftAllowed = Math.floor(this.chart.chartArea.right - tooltipWidth -2);
+ x = Math.floor(x);
+ if (x <= maxLeftAllowed) {
+ if (x >= minLeftAllowed) {
+ left = x;
+ } else {
+ left = minLeftAllowed;
+ }
+ } else {
+ left = maxLeftAllowed;
+ }
+ tooltip.style.left = left + 'px';
+ },
+ /**
+ * Used to format correctly the values in tooltips and yAxes
+ *
+ * @private
+ * @param {number} value
+ * @returns {string} The value formatted using fieldUtils.format.float
+ */
+ _formatValue: function (value) {
+ var measureField = this.fields[this.state.measure];
+ var formatter = fieldUtils.format.float;
+ var formatedValue = formatter(value, measureField, FORMAT_OPTIONS);
+ return formatedValue;
+ },
+ /**
+ * Used any time we need a new color in our charts.
+ *
+ * @private
+ * @param {number} index
+ * @returns {string} a color in HEX format
+ */
+ _getColor: function (index) {
+ return COLORS[index % COLOR_NB];
+ },
+ /**
+ * Determines the initial section of the labels array
+ * over a dataset has to be completed. The section only depends
+ * on the datasets origins.
+ *
+ * @private
+ * @param {number} originIndex
+ * @param {number} defaultLength
+ * @returns {number}
+ */
+ _getDatasetDataLength: function (originIndex, defaultLength) {
+ if (_.contains(['bar', 'line'], this.state.mode) && this.state.comparisonFieldIndex === 0) {
+ return this.dateClasses.dateSets[originIndex].length;
+ }
+ return defaultLength;
+ },
+ /**
+ * Determines to which dataset belong the data point
+ *
+ * @private
+ * @param {Object} dataPt
+ * @returns {string}
+ */
+ _getDatasetLabel: function (dataPt) {
+ if (_.contains(['bar', 'line'], this.state.mode)) {
+ // ([origin] + second to last groupBys) or measure
+ var datasetLabel = dataPt.labels.slice(1).join("/");
+ if (this.state.origins.length > 1) {
+ datasetLabel = this.state.origins[dataPt.originIndex] +
+ (datasetLabel ? ('/' + datasetLabel) : '');
+ }
+ datasetLabel = datasetLabel || this.fields[this.state.measure].string;
+ return datasetLabel;
+ }
+ return this.state.origins[dataPt.originIndex];
+ },
+ /**
+ * Returns an object used to style chart elements independently from the datasets.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getElementOptions: function () {
+ var elementOptions = {};
+ if (this.state.mode === 'bar') {
+ elementOptions.rectangle = {borderWidth: 1};
+ } else if (this.state.mode === 'line') {
+ elementOptions.line = {
+ tension: 0,
+ fill: false,
+ };
+ }
+ return elementOptions;
+ },
+ /**
+ * Returns a DateClasses instance used to manage equivalence of dates.
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ * @returns {DateClasses}
+ */
+ _getDateClasses: function (dataPoints) {
+ var self = this;
+ var dateSets = this.state.origins.map(function () {
+ return [];
+ });
+ dataPoints.forEach(function (dataPt) {
+ dateSets[dataPt.originIndex].push(dataPt.labels[self.state.comparisonFieldIndex]);
+ });
+ dateSets = dateSets.map(function (dateSet) {
+ return _.uniq(dateSet);
+ });
+ return new DateClasses(dateSets);
+ },
+ /**
+ * Determines over which label is the data point
+ *
+ * @private
+ * @param {Object} dataPt
+ * @returns {Array}
+ */
+ _getLabel: function (dataPt) {
+ var i = this.state.comparisonFieldIndex;
+ if (_.contains(['bar', 'line'], this.state.mode)) {
+ if (i === 0) {
+ return [this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i])];
+ } else {
+ return dataPt.labels.slice(0, 1);
+ }
+ } else if (i === 0) {
+ return Array.prototype.concat.apply([], [
+ this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i]),
+ dataPt.labels.slice(i+1)
+ ]);
+ } else {
+ return dataPt.labels;
+ }
+ },
+ /**
+ * Returns the options used to generate the chart legend.
+ *
+ * @private
+ * @param {Number} datasetsCount
+ * @returns {Object}
+ */
+ _getLegendOptions: function (datasetsCount) {
+ var legendOptions = {
+ display: datasetsCount <= MAX_LEGEND_LENGTH,
+ // position: this.state.mode === 'pie' ? 'right' : 'top',
+ position: 'top',
+ onHover: this._onlegendTooltipHover.bind(this),
+ onLeave: this._onLegendTootipLeave.bind(this),
+ };
+ var self = this;
+ if (_.contains(['bar', 'line'], this.state.mode)) {
+ var referenceColor;
+ if (this.state.mode === 'bar') {
+ referenceColor = 'backgroundColor';
+ } else {
+ referenceColor = 'borderColor';
+ }
+ legendOptions.labels = {
+ generateLabels: function (chart) {
+ var data = chart.data;
+ return data.datasets.map(function (dataset, i) {
+ return {
+ text: self._shortenLabel(dataset.label),
+ fullText: dataset.label,
+ fillStyle: dataset[referenceColor],
+ hidden: !chart.isDatasetVisible(i),
+ lineCap: dataset.borderCapStyle,
+ lineDash: dataset.borderDash,
+ lineDashOffset: dataset.borderDashOffset,
+ lineJoin: dataset.borderJoinStyle,
+ lineWidth: dataset.borderWidth,
+ strokeStyle: dataset[referenceColor],
+ pointStyle: dataset.pointStyle,
+ datasetIndex: i,
+ };
+ });
+ },
+ };
+ } else {
+ legendOptions.labels = {
+ generateLabels: function (chart) {
+ var data = chart.data;
+ var metaData = data.datasets.map(function (dataset, index) {
+ return chart.getDatasetMeta(index).data;
+ });
+ return data.labels.map(function (label, i) {
+ var hidden = metaData.reduce(
+ function (hidden, data) {
+ if (data[i]) {
+ hidden = hidden || data[i].hidden;
+ }
+ return hidden;
+ },
+ false
+ );
+ var fullText = self._relabelling(label);
+ var text = self._shortenLabel(fullText);
+ return {
+ text: text,
+ fullText: fullText,
+ fillStyle: label.isNoData ? '#d3d3d3' : self._getColor(i),
+ hidden: hidden,
+ index: i,
+ };
+ });
+ },
+ };
+ }
+ return legendOptions;
+ },
+ /**
+ * Returns the options used to generate the chart axes.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getScaleOptions: function () {
+ var self = this;
+ if (_.contains(['bar', 'line'], this.state.mode)) {
+ return {
+ xAxes: [{
+ type: 'category',
+ scaleLabel: {
+ display: this.state.processedGroupBy.length && !this.isEmbedded,
+ labelString: this.state.processedGroupBy.length ?
+ this.fields[this.state.processedGroupBy[0].split(':')[0]].string : '',
+ },
+ ticks: {
+ // don't use bind: callback is called with 'index' as second parameter
+ // with value labels.indexOf(label)!
+ callback: function (label) {
+ return self._relabelling(label);
+ },
+ },
+ }],
+ yAxes: [{
+ type: 'linear',
+ scaleLabel: {
+ display: !this.isEmbedded,
+ labelString: this.fields[this.state.measure].string,
+ },
+ ticks: {
+ callback: this._formatValue.bind(this),
+ suggestedMax: 0,
+ suggestedMin: 0,
+ }
+ }],
+ };
+ }
+ return {};
+ },
+ /**
+ * Extracts the important information from a tooltipItem generated by Charts.js
+ * (a tooltip item corresponds to a line (different from measure name) of a tooltip)
+ *
+ * @private
+ * @param {Object} item
+ * @param {Object} data
+ * @returns {Object}
+ */
+ _getTooltipItemContent: function (item, data) {
+ var dataset = data.datasets[item.datasetIndex];
+ var label = data.labels[item.index];
+ var value;
+ var boxColor;
+ if (this.state.mode === 'bar') {
+ label = this._relabelling(label, dataset.originIndex);
+ if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) {
+ label = label + "/" + dataset.label;
+ }
+ value = this._formatValue(item.yLabel);
+ boxColor = dataset.backgroundColor;
+ } else if (this.state.mode === 'line') {
+ label = this._relabelling(label, dataset.originIndex);
+ if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) {
+ label = label + "/" + dataset.label;
+ }
+ value = this._formatValue(item.yLabel);
+ boxColor = dataset.borderColor;
+ } else {
+ if (label.isNoData) {
+ value = this._formatValue(0);
+ } else {
+ value = this._formatValue(dataset.data[item.index]);
+ }
+ label = this._relabelling(label, dataset.originIndex);
+ if (this.state.origins.length > 1) {
+ label = dataset.label + "/" + label;
+ }
+ boxColor = dataset.backgroundColor[item.index];
+ }
+ return {
+ label: label,
+ value: value,
+ boxColor: boxColor,
+ };
+ },
+ /**
+ * This function extracts the information from the data points in tooltipModel.dataPoints
+ * (corresponding to datapoints over a given label determined by the mouse position)
+ * that will be displayed in a custom tooltip.
+ *
+ * @private
+ * @param {Object} tooltipModel see chartjs documentation
+ * @return {Object[]}
+ */
+ _getTooltipItems: function (tooltipModel) {
+ var self = this;
+ var data = this.chart.config.data;
+
+ var orderedItems = tooltipModel.dataPoints.sort(function (dPt1, dPt2) {
+ return dPt2.yLabel - dPt1.yLabel;
+ });
+ return orderedItems.reduce(
+ function (acc, item) {
+ acc.push(self._getTooltipItemContent(item, data));
+ return acc;
+ },
+ []
+ );
+ },
+ /**
+ * Returns the options used to generate chart tooltips.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getTooltipOptions: function () {
+ var tooltipOptions = {
+ // disable Chart.js tooltips
+ enabled: false,
+ custom: this._customTooltip.bind(this),
+ };
+ if (this.state.mode === 'line') {
+ tooltipOptions.mode = 'index';
+ tooltipOptions.intersect = false;
+ }
+ 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.
+ *
+ * @private
+ * @param {Array[]} list
+ * @param {Array} label
+ * @returns {number}
+ */
+ _indexOf: function (list, label) {
+ var index = -1;
+ for (var j = 0; j < list.length; j++) {
+ var otherLabel = list[j];
+ if (label.length === otherLabel.length) {
+ var equal = true;
+ for (var i = 0; i < label.length; i++) {
+ if (label[i] !== otherLabel[i]) {
+ equal = false;
+ }
+ }
+ if (equal) {
+ index = j;
+ break;
+ }
+ }
+ }
+ return index;
+ },
+ /**
+ * Separate dataPoints coming from the read_group(s) into different datasets.
+ * This function returns the parameters data and labels used to produce the charts.
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ * @param {function} getLabel,
+ * @param {function} getDatasetLabel, determines to which dataset belong a given data point
+ * @param {function} [getDatasetDataLength], determines the initial section of the labels array
+ * over which the datasets have to be completed. These sections only depend
+ * on the datasets origins. Default is the constant function _ => labels.length.
+ * @returns {Object} the parameter data used to instantiate the chart.
+ */
+ _prepareData: function (dataPoints) {
+ var self = this;
+
+ var labelMap = {};
+ var labels = dataPoints.reduce(
+ function (acc, dataPt) {
+ var label = self._getLabel(dataPt);
+ var labelKey = dataPt.resId + ':' + JSON.stringify(label);
+ var index = labelMap[labelKey];
+ if (index === undefined) {
+ labelMap[labelKey] = dataPt.labelIndex = acc.length;
+ acc.push(label);
+ }
+ else{
+ dataPt.labelIndex = index;
+ }
+ return acc;
+ },
+ []
+ );
+
+ 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,
+ };
+ };
+
+ // dataPoints --> datasets
+ var datasets = _.values(dataPoints.reduce(
+ function (acc, dataPt) {
+ var datasetLabel = self._getDatasetLabel(dataPt);
+ if (!(datasetLabel in acc)) {
+ acc[datasetLabel] = newDataset(datasetLabel, dataPt.originIndex);
+ }
+ var labelIndex = dataPt.labelIndex;
+ acc[datasetLabel].data[labelIndex] = dataPt.value;
+ acc[datasetLabel].domain[labelIndex] = dataPt.domain;
+ return acc;
+ },
+ {}
+ ));
+
+ // sort by origin
+ datasets = datasets.sort(function (dataset1, dataset2) {
+ return dataset1.originIndex - dataset2.originIndex;
+ });
+
+ return {
+ datasets: datasets,
+ labels: labels,
+ };
+ },
+ /**
+ * Prepare options for the chart according to the current mode (= chart type).
+ * This function returns the parameter options used to instantiate the chart
+ *
+ * @private
+ * @param {number} datasetsCount
+ * @returns {Object} the chart options used for the current mode
+ */
+ _prepareOptions: function (datasetsCount) {
+ 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.
+ * The idea is that the getLabel function is in general not invertible but
+ * it is when restricted to the set of dataPoints coming from a same origin.
+
+ * @private
+ * @param {Array} label
+ * @param {Array} originIndex
+ * @returns {string}
+ */
+ _relabelling: function (label, originIndex) {
+ if (label.isNoData) {
+ return label[0];
+ }
+ var i = this.state.comparisonFieldIndex;
+ if (_.contains(['bar', 'line'], this.state.mode) && i === 0) {
+ // here label is an array of length 1 and contains a number
+ return this.dateClasses.representative(label, originIndex) || '';
+ } else if (this.state.mode === 'pie' && i === 0) {
+ // here label is an array of length at least one containing string or numbers
+ var labelCopy = label.slice(0);
+ if (originIndex !== undefined) {
+ labelCopy.splice(i, 1, this.dateClasses.representative(label[i], originIndex));
+ } else {
+ labelCopy.splice(i, 1, this.dateClasses.dateClassMembers(label[i]));
+ }
+ return labelCopy.join('/');
+ }
+ // here label is an array containing strings or numbers.
+ return label.join('/') || _t('Total');
+ },
+ /**
+ * Render the chart or display a message error in case data is not good enough.
+ *
+ * Note that This method is synchronous, but the actual rendering is done
+ * asynchronously. The reason for that is that Chart.js needs to be in the
+ * DOM to correctly render itself. So, we trick Odoo by returning
+ * immediately, then we render the chart when the widget is in the DOM.
+ *
+ * @override
+ */
+ async _renderView() {
+ if (this.chart) {
+ this.chart.destroy();
+ }
+ this.$el.empty();
+ if (!_.contains(CHART_TYPES, this.state.mode)) {
+ this.trigger_up('warning', {
+ title: _t('Invalid mode for chart'),
+ message: _t('Cannot render chart with mode : ') + this.state.mode
+ });
+ }
+ var dataPoints = this._filterDataPoints();
+ dataPoints = this._sortDataPoints(dataPoints);
+ if (this.isInDOM) {
+ this._renderTitle();
+
+ // detect if some pathologies are still present after the filtering
+ if (this.state.mode === 'pie') {
+ const someNegative = dataPoints.some(dataPt => dataPt.value < 0);
+ const somePositive = dataPoints.some(dataPt => dataPt.value > 0);
+ if (someNegative && somePositive) {
+ const context = {
+ title: _t("Invalid data"),
+ description: [
+ _t("Pie chart cannot mix positive and negative numbers. "),
+ _t("Try to change your domain to only display positive results")
+ ].join("")
+ };
+ this._renderNoContentHelper(context);
+ return;
+ }
+ }
+
+ if (this.state.isSample && !this.isEmbedded) {
+ this._renderNoContentHelper();
+ }
+
+ // only render the graph if the widget is already in the DOM (this
+ // happens typically after an update), otherwise, it will be
+ // rendered when the widget will be attached to the DOM (see
+ // 'on_attach_callback')
+ var $canvasContainer = $('<div/>', {class: 'o_graph_canvas_container'});
+ var $canvas = $('<canvas/>').attr('id', this.chartId);
+ $canvasContainer.append($canvas);
+ this.$el.append($canvasContainer);
+
+ var i = this.state.comparisonFieldIndex;
+ if (i === 0) {
+ this.dateClasses = this._getDateClasses(dataPoints);
+ }
+ if (this.state.mode === 'bar') {
+ this._renderBarChart(dataPoints);
+ } else if (this.state.mode === 'line') {
+ this._renderLineChart(dataPoints);
+ } else if (this.state.mode === 'pie') {
+ this._renderPieChart(dataPoints);
+ }
+ }
+ },
+ /**
+ * create bar chart.
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ */
+ _renderBarChart: function (dataPoints) {
+ var self = this;
+
+ // prepare data
+ var data = this._prepareData(dataPoints);
+
+ data.datasets.forEach(function (dataset, index) {
+ // used when stacked
+ dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined;
+ // set dataset color
+ var color = self._getColor(index);
+ dataset.backgroundColor = color;
+ });
+
+ // prepare options
+ var options = this._prepareOptions(data.datasets.length);
+
+ // create chart
+ var ctx = document.getElementById(this.chartId);
+ this.chart = new Chart(ctx, {
+ type: 'bar',
+ data: data,
+ options: options,
+ });
+ },
+ /**
+ * create line chart.
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ */
+ _renderLineChart: function (dataPoints) {
+ var self = this;
+
+ // prepare data
+ var data = this._prepareData(dataPoints);
+ data.datasets.forEach(function (dataset, index) {
+ if (self.state.processedGroupBy.length <= 1 && self.state.origins.length > 1) {
+ if (dataset.originIndex === 0) {
+ dataset.fill = 'origin';
+ dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4);
+ dataset.borderColor = hexToRGBA(COLORS[0], 1);
+ } else if (dataset.originIndex === 1) {
+ dataset.borderColor = hexToRGBA(COLORS[1], 1);
+ } else {
+ dataset.borderColor = self._getColor(index);
+ }
+ } else {
+ dataset.borderColor = self._getColor(index);
+ }
+ if (data.labels.length === 1) {
+ // shift of the real value to right. This is done to center the points in the chart
+ // See data.labels below in Chart parameters
+ dataset.data.unshift(undefined);
+ }
+ dataset.pointBackgroundColor = dataset.borderColor;
+ dataset.pointBorderColor = 'rgba(0,0,0,0.2)';
+ });
+ if (data.datasets.length === 1) {
+ const dataset = data.datasets[0];
+ dataset.fill = 'origin';
+ dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4);
+ }
+
+ // center the points in the chart (without that code they are put on the left and the graph seems empty)
+ data.labels = data.labels.length > 1 ?
+ data.labels :
+ Array.prototype.concat.apply([], [[['']], data.labels, [['']]]);
+
+ // prepare options
+ var options = this._prepareOptions(data.datasets.length);
+
+ // create chart
+ var ctx = document.getElementById(this.chartId);
+ this.chart = new Chart(ctx, {
+ type: 'line',
+ data: data,
+ options: options,
+ });
+ },
+ /**
+ * create pie chart
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ */
+ _renderPieChart: function (dataPoints) {
+ var self = this;
+ // prepare data
+ var data = {};
+ var colors = [];
+ const allZero = dataPoints.every(dataPt => dataPt.value === 0);
+ if (allZero) {
+ // add fake data to display a pie chart with a grey zone associated
+ // with every origin
+ data.labels = [NO_DATA];
+ data.datasets = this.state.origins.map(function (origin) {
+ return {
+ label: origin,
+ data: [1],
+ backgroundColor: ['#d3d3d3'],
+ };
+ });
+ } else {
+ data = this._prepareData(dataPoints);
+ // give same color to same groups from different origins
+ colors = data.labels.map(function (label, index) {
+ return self._getColor(index);
+ });
+ data.datasets.forEach(function (dataset) {
+ dataset.backgroundColor = colors;
+ dataset.borderColor = 'rgba(255,255,255,0.6)';
+ });
+ // make sure there is a zone associated with every origin
+ var representedOriginIndexes = data.datasets.map(function (dataset) {
+ return dataset.originIndex;
+ });
+ var addNoDataToLegend = false;
+ var fakeData = (new Array(data.labels.length)).concat([1]);
+ this.state.origins.forEach(function (origin, originIndex) {
+ if (!_.contains(representedOriginIndexes, originIndex)) {
+ data.datasets.splice(originIndex, 0, {
+ label: origin,
+ data: fakeData,
+ backgroundColor: colors.concat(['#d3d3d3']),
+ });
+ addNoDataToLegend = true;
+ }
+ });
+ if (addNoDataToLegend) {
+ data.labels.push(NO_DATA);
+ }
+ }
+
+ // prepare options
+ var options = this._prepareOptions(data.datasets.length);
+
+ // create chart
+ var ctx = document.getElementById(this.chartId);
+ this.chart = new Chart(ctx, {
+ type: 'pie',
+ data: data,
+ options: options,
+ });
+ },
+ /**
+ * Add the graph title (if any) above the canvas
+ *
+ * @private
+ */
+ _renderTitle: function () {
+ if (this.title) {
+ this.$el.prepend($('<label/>', {
+ text: this.title,
+ }));
+ }
+ },
+ /**
+ * Used to avoid too long legend items
+ *
+ * @private
+ * @param {string} label
+ * @returns {string} shortened version of the input label
+ */
+ _shortenLabel: function (label) {
+ // string returned could be 'wrong' if a groupby value contain a '/'!
+ var groups = label.split("/");
+ var shortLabel = groups.slice(0, 3).join("/");
+ if (shortLabel.length > 30) {
+ shortLabel = shortLabel.slice(0, 30) + '...';
+ } else if (groups.length > 3) {
+ shortLabel = shortLabel + '/...';
+ }
+ return shortLabel;
+ },
+ /**
+ * Sort datapoints according to the current order (ASC or DESC).
+ *
+ * Note: this should be moved to the model at some point.
+ *
+ * @private
+ * @param {Object[]} dataPoints
+ * @returns {Object[]} sorted dataPoints if orderby set on state
+ */
+ _sortDataPoints(dataPoints) {
+ if (!Object.keys(this.state.timeRanges).length && this.state.orderBy &&
+ ['bar', 'line'].includes(this.state.mode) && this.state.groupBy.length) {
+ // group data by their x-axis value, and then sort datapoints
+ // based on the sum of values by group in ascending/descending order
+ const groupByFieldName = this.state.groupBy[0].split(':')[0];
+ const groupedByMany2One = this.fields[groupByFieldName].type === 'many2one';
+ const groupedDataPoints = {};
+ dataPoints.forEach(function (dataPoint) {
+ const key = groupedByMany2One ? dataPoint.resId : dataPoint.labels[0];
+ groupedDataPoints[key] = groupedDataPoints[key] || [];
+ groupedDataPoints[key].push(dataPoint);
+ });
+ dataPoints = _.sortBy(groupedDataPoints, function (group) {
+ return group.reduce((sum, dataPoint) => sum + dataPoint.value, 0);
+ });
+ dataPoints = dataPoints.flat();
+ if (this.state.orderBy === 'desc') {
+ dataPoints = dataPoints.reverse('value');
+ }
+ }
+ return dataPoints;
+ },
+
+ //--------------------------------------------------------------------------
+ // 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
+ * full text is displayed.
+ *
+ * @private
+ * @param {MouseEvent} e
+ * @param {Object} legendItem
+ */
+ _onlegendTooltipHover: function (e, legendItem) {
+ // set cursor pointer on hover of legend
+ e.target.style.cursor = 'pointer';
+ // The string legendItem.text is an initial segment of legendItem.fullText.
+ // If the two coincide, no need to generate a tooltip.
+ // If a tooltip for the legend already exists, it is already good and don't need
+ // to be recreated.
+ if (legendItem.text === legendItem.fullText || this.$legendTooltip) {
+ return;
+ }
+
+ const chartAreaLeft = this.chart.chartArea.left;
+ const chartAreaRight = this.chart.chartArea.right;
+ const rendererTop = this.$el[0].getBoundingClientRect().top;
+
+ this.$legendTooltip = $('<div>', {
+ class: "o_tooltip_legend",
+ text: legendItem.fullText,
+ css: {
+ maxWidth: Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px',
+ top: (e.clientY - rendererTop) + 'px',
+ }
+ });
+ const $container = this.$el.find('.o_graph_canvas_container');
+ $container.append(this.$legendTooltip);
+
+ this._fixTooltipLeftPosition(this.$legendTooltip[0], e.clientX);
+ },
+ /**
+ * If there's a legend tooltip and the user mouse out of the corresponding
+ * legend item, the tooltip is removed.
+ *
+ * @private
+ */
+ _onLegendTootipLeave: function (e) {
+ // remove cursor style pointer on mouseleave from legend
+ e.target.style.cursor = "";
+ if (this.$legendTooltip) {
+ this.$legendTooltip.remove();
+ this.$legendTooltip = null;
+ }
+ },
+});
+});
diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js
new file mode 100644
index 00000000..759817c8
--- /dev/null
+++ b/addons/web/static/src/js/views/graph/graph_view.js
@@ -0,0 +1,162 @@
+odoo.define('web.GraphView', function (require) {
+"use strict";
+
+/**
+ * The Graph View is responsible to display a graphical (meaning: chart)
+ * representation of the current dataset. As of now, it is currently able to
+ * display data in three types of chart: bar chart, line chart and pie chart.
+ */
+
+var AbstractView = require('web.AbstractView');
+var core = require('web.core');
+var GraphModel = require('web.GraphModel');
+var Controller = require('web.GraphController');
+var GraphRenderer = require('web.GraphRenderer');
+
+var _t = core._t;
+var _lt = core._lt;
+
+var searchUtils = require('web.searchUtils');
+var GROUPABLE_TYPES = searchUtils.GROUPABLE_TYPES;
+
+var GraphView = AbstractView.extend({
+ display_name: _lt('Graph'),
+ icon: 'fa-bar-chart',
+ jsLibs: [
+ '/web/static/lib/Chart/Chart.js',
+ ],
+ config: _.extend({}, AbstractView.prototype.config, {
+ Model: GraphModel,
+ Controller: Controller,
+ Renderer: GraphRenderer,
+ }),
+ viewType: 'graph',
+ searchMenuTypes: ['filter', 'groupBy', 'comparison', 'favorite'],
+
+ /**
+ * @override
+ */
+ init: function (viewInfo, params) {
+ this._super.apply(this, arguments);
+
+ const additionalMeasures = params.additionalMeasures || [];
+ let measure;
+ const measures = {};
+ const measureStrings = {};
+ let groupBys = [];
+ const groupableFields = {};
+ this.fields.__count__ = { string: _t("Count"), type: 'integer' };
+
+ this.arch.children.forEach(field => {
+ let fieldName = field.attrs.name;
+ if (fieldName === "id") {
+ return;
+ }
+ const interval = field.attrs.interval;
+ if (interval) {
+ fieldName = fieldName + ':' + interval;
+ }
+ if (field.attrs.type === 'measure') {
+ const { string } = this.fields[fieldName];
+ measure = fieldName;
+ measures[fieldName] = {
+ description: string,
+ fieldName,
+ groupNumber: 0,
+ isActive: false,
+ itemType: 'measure',
+ };
+ } else {
+ groupBys.push(fieldName);
+ }
+ if (field.attrs.string) {
+ measureStrings[fieldName] = field.attrs.string;
+ }
+ });
+
+ for (const name in this.fields) {
+ const field = this.fields[name];
+ if (name !== 'id' && field.store === true) {
+ if (
+ ['integer', 'float', 'monetary'].includes(field.type) ||
+ additionalMeasures.includes(name)
+ ) {
+ measures[name] = {
+ description: field.string,
+ fieldName: name,
+ groupNumber: 0,
+ isActive: false,
+ itemType: 'measure',
+ };
+ }
+ if (GROUPABLE_TYPES.includes(field.type)) {
+ groupableFields[name] = field;
+ }
+ }
+ }
+ for (const name in measureStrings) {
+ if (measures[name]) {
+ measures[name].description = measureStrings[name];
+ }
+ }
+
+ // Remove invisible fields from the measures
+ this.arch.children.forEach(field => {
+ let fieldName = field.attrs.name;
+ if (field.attrs.invisible && py.eval(field.attrs.invisible)) {
+ groupBys = groupBys.filter(groupBy => groupBy !== fieldName);
+ if (fieldName in groupableFields) {
+ delete groupableFields[fieldName];
+ }
+ if (!additionalMeasures.includes(fieldName)) {
+ delete measures[fieldName];
+ }
+ }
+ });
+
+ const sortedMeasures = Object.values(measures).sort((a, b) => {
+ const descA = a.description.toLowerCase();
+ const descB = b.description.toLowerCase();
+ return descA > descB ? 1 : descA < descB ? -1 : 0;
+ });
+ const countMeasure = {
+ description: _t("Count"),
+ fieldName: '__count__',
+ groupNumber: 1,
+ isActive: false,
+ itemType: 'measure',
+ };
+ 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.orderBy = this.arch.attrs.order;
+ this.loadParams.measure = measure || '__count__';
+ this.loadParams.groupBys = groupBys;
+ this.loadParams.fields = this.fields;
+ this.loadParams.comparisonDomain = params.comparisonDomain;
+ this.loadParams.stacked = this.arch.attrs.stacked !== "False";
+ },
+});
+
+return GraphView;
+
+});