diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/views/graph | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (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.js | 356 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_model.js | 322 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_renderer.js | 1099 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_view.js | 162 |
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; + +}); |
