diff options
| author | Géry Debongnie <ged@odoo.com> | 2017-03-05 10:05:56 +0100 |
|---|---|---|
| committer | Aaron Bohy <aab@odoo.com> | 2017-04-11 19:44:38 +0200 |
| commit | 905e01921f3c3ef43ace6fc537d1d8c0d280002c (patch) | |
| tree | 265c43ab21c5401a5d9f0f3d64d86f1ac417451c /addons/web/static/src/js/views/graph | |
| parent | 54d4c73af4ad05c7640d28c1892247c9538b8017 (diff) | |
[REF] web, *: redesign all JS views
This commit introduce a full redesign of all JS views. We started
basically from scratch. The goal was to unify all the various views
under a common framework, to make them testable, to make then usable in
different conditions (in studio, or in the frontend), and to make our
lives easier.
Some important points are:
- we introduced new coding guidelines (camelCase, 80 chars width, ...)
- we have a brand new testing framework (still QUnit based)
- kanban view moved to the web addon
- calendar view (formerly web_calender) moved to web as well
- the tree view was removed
- all new code should be documented
We hope that this code is the start of a new era for the Odoo web
client, we want to have a high quality codebase, well documented, well
tested, well designed.
Work done by the framework team: mostly aab, ged, chm, dmo, qsm
Diffstat (limited to 'addons/web/static/src/js/views/graph')
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_controller.js | 128 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_model.js | 188 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_renderer.js | 334 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/graph/graph_view.js | 69 |
4 files changed, 719 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 00000000000..040311a3919 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_controller.js @@ -0,0 +1,128 @@ +odoo.define('web.GraphController', function (require) { +"use strict"; +/*--------------------------------------------------------- + * Odoo Graph view + *---------------------------------------------------------*/ + +var AbstractController = require('web.AbstractController'); +var core = require('web.core'); + +var qweb = core.qweb; + +var GraphController = AbstractController.extend({ + className: 'o_graph', + /** + * @override + * @param {Widget} parent + * @param {GraphModel} model + * @param {GraphRenderer} renderer + * @param {Object} params + * @param {string[]} params.measures + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.measures = params.measures; + }, + /** + * @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(); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * 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) { + if ($node) { + var context = {measures: _.pairs(_.omit(this.measures, '__count__'))}; + this.$buttons = $(qweb.render('GraphView.buttons', context)); + this.$measureList = this.$buttons.find('.o_graph_measures_list'); + this.$buttons.find('button').tooltip(); + this.$buttons.click(this._onButtonClick.bind(this)); + this._updateButtons(); + this.$buttons.appendTo($node); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @todo remove this and directly calls update. Update should be overridden + * and modified to call _updateButtons + * + * @param {string} mode one of 'pie', 'line' or 'bar' + */ + _setMode: function (mode) { + this.update({mode: mode}); + this._updateButtons(); + }, + /** + * @todo same as _setMode + * + * @param {string} measure should be a valid (and aggregatable) field name + */ + _setMeasure: function (measure) { + var self = this; + this.update({measure: measure}).then(function () { + self._updateButtons(); + }); + }, + /** + * makes sure that the buttons in the control panel matches the current + * state (so, correct active buttons and stuff like that) + */ + _updateButtons: function () { + 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.$measureList.find('li').each(function (index, li) { + $(li).toggleClass('selected', $(li).data('field') === state.measure); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Do what need to be done when a button from the control panel is clicked. + * + * @param {MouseEvent} event + */ + _onButtonClick: function (event) { + var $target = $(event.target); + if ($target.hasClass('o_graph_button')) { + this._setMode($target.data('mode')); + } else if ($target.parents('.o_graph_measures_list').length) { + event.preventDefault(); + event.stopPropagation(); + var parent = $target.parent(); + var field = parent.data('field'); + this._setMeasure(field); + } + }, +}); + +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 00000000000..73578f5c3bc --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -0,0 +1,188 @@ +odoo.define('web.GraphModel', function (require) { +"use strict"; + +/** + * The graph model is responsible for fetching and processing data from the + * server. It basically just do a read_group and format/normalize data. + */ + +var core = require('web.core'); +var AbstractModel = require('web.AbstractModel'); + +var _t = core._t; + +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. + * + * @todo Adding the fields parameter looks wrong. If the renderer or the + * controller need the fields, they should get it via their init method. + * + * @returns {Object} + */ + get: function () { + return _.extend({}, this.chart, { + fields: this.fields + }); + }, + /** + * 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 {string[]} params.groupedBy + * @param {string} params.initialMode one of 'pie', 'bar', 'line + * @param {string} params.initialMeasure a valid field name + * @param {string[]} params.initialGroupBys a list of valid field names + * @param {Object} params.context + * @param {string[]} params.domain + * @returns {Deferred} The deferred does not return a handle, we don't need + * to keep track of various entities. + */ + load: function (params) { + this.initialGroupBys = params.initialGroupBys; + this.fields = params.fields; + this.modelName = params.modelName; + if (!params.groupedBy.length) { + var groupedBy = params.context.graph_groupbys || + (params.groupedBy.length && params.groupedBy) || + this.initialGroupBys; + params.groupedBy = groupedBy.slice(0); + } + this.chart = { + data: [], + groupedBy: params.groupedBy, + measure: params.initialMeasure, + mode: params.initialMode, + domain: params.domain, + context: params.context, + }; + 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 {string[]} [params.domain] + * @param {string[]} [params.groupBy] + * @param {string} [params.mode] one of 'bar', 'pie', 'line' + * @param {string} [params.measure] a valid field name + * @returns {Deferred} + */ + reload: function (handle, params) { + if ('domain' in params) { + this.chart.domain = params.domain; + } + if ('groupBy' in params) { + if (!params.groupBy.length) { + this.chart.groupedBy = this.initialGroupBys; + } else { + this.chart.groupedBy = params.groupBy; + } + } + if ('measure' in params) { + this.chart.measure = params.measure; + } + if ('mode' in params) { + this.chart.mode = params.mode; + return $.when(); + } + return this._loadGraph(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Fetch and process graph data. It is basically a read_group with correct + * fields. 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 + * + * @returns {Deferred} + */ + _loadGraph: function () { + var fields = _.map(this.chart.groupedBy, function (groupBy) { + return groupBy.split(':')[0]; + }); + if (this.chart.measure !== '__count__') { + fields = fields.concat(this.chart.measure); + } + return this.performModelRPC(this.modelName, 'read_group', [], { + context: this.chart.context, + domain: this.chart.domain, + fields: fields, + groupby: this.chart.groupedBy, + lazy: false, + }).then(this._processData.bind(this)); + }, + /** + * Since read_group is insane and returns its result on different keys + * depending of some input, we have to normalize the result. + * The final chart data is added to this.chart object. + * + * @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 + * + * @param {any} raw_data result from the read_group + */ + _processData: function (raw_data) { + var self = this; + var is_count = this.chart.measure === '__count__'; + var data_pt, labels; + + this.chart.data = []; + for (var i = 0; i < raw_data.length; i++) { + data_pt = raw_data[i]; + labels = _.map(this.chart.groupedBy, function (field) { + return self._sanitizeValue(data_pt[field], field); + }); + this.chart.data.push({ + value: is_count ? data_pt.__count : data_pt[this.chart.measure], + labels: labels + }); + } + }, + /** + * Helper function (for _processData), turns various values in a usable + * string form, that we can display in the interface. + * + * @param {any} value some value received by the read_group rpc + * @param {string} field the name of the corresponding field + * @returns {string} + */ + _sanitizeValue: function (value, field) { + if (value === false) return _t("Undefined"); + if (value instanceof Array) return value[1]; + if (field && this.fields[field] && (this.fields[field].type === 'selection')) { + var selected = _.where(this.fields[field].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 00000000000..e5fe00cc473 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -0,0 +1,334 @@ +odoo.define('web.GraphRenderer', function (require) { +"use strict"; + +/** + * The graph renderer turns the data from the graph model into a nice looking + * svg chart. This code uses the nvd3 library. + * + * Note that we use a custom build for the nvd3, with only the model we actually + * use. + */ + +var AbstractRenderer = require('web.AbstractRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var formats = require('web.formats'); + +var _t = core._t; +var qweb = core.qweb; + +var CHART_TYPES = ['pie', 'bar', 'line']; + +// hide top legend when too many items for device size +var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class); + +return AbstractRenderer.extend({ + className: "o_graph_svg_container", + /** + * @override + * @param {Widget} parent + * @param {Object} state + * @param {Object} params + * @param {boolean} params.stacked + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.stacked = params.stacked; + this.$el.css({minWidth: '100px', minHeight: '100px'}); + }, + /** + * @override + */ + destroy: function () { + nv.utils.offWindowResize(this.to_remove); + this._super(); + }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Render the chart. + * + * Note that This method is synchronous, but the actual rendering is done + * asynchronously (in a setTimeout). The reason for that is that nvd3/d3 + * needs to be in the DOM to correctly render itself. So, we trick Odoo by + * returning immediately, then wait a tiny interval before actually + * displaying the data. + * + * @returns {Deferred} The _super deferred is actually resolved immediately + */ + _render: function () { + if (this.to_remove) { + nv.utils.offWindowResize(this.to_remove); + } + if (!_.contains(CHART_TYPES, this.state.mode)) { + this.$el.empty(); + this.trigger_up('warning', { + title: _t('Invalid mode for chart'), + message: _t('Cannot render chart with mode : ') + this.state.mode + }); + } else if (!this.state.data.length) { + this.$el.empty(); + this.$el.append(qweb.render('GraphView.error', { + title: _t("No data to display"), + description: _t("No data available for this chart. " + + "Try to add some records, or make sure that " + + "there is no active filter in the search bar."), + })); + } else { + var self = this; + setTimeout(function () { + self.$el.empty(); + var chart = self['_render' + _.str.capitalize(self.state.mode) + 'Chart'](); + if (chart) { + self.to_remove = chart.update; + nv.utils.onWindowResize(chart.update); + chart.tooltip.chartContainer(self.el); + } + }, 0); + } + return this._super.apply(this, arguments); + }, + + /** + * Helper function to set up data properly for the multiBarChart model in + * nvd3. + * + * @returns {nvd3 chart} + */ + _renderBarChart: function () { + // prepare data for bar chart + var self = this; + var data, values; + var measure = this.state.fields[this.state.measure].string; + + // zero groupbys + if (this.state.groupedBy.length === 0) { + data = [{ + values: [{ + x: measure, + y: this.state.data[0].value}], + key: measure + }]; + } + // one groupby + if (this.state.groupedBy.length === 1) { + values = this.state.data.map(function (datapt) { + return {x: datapt.labels, y: datapt.value}; + }); + data = [ + { + values: values, + key: measure, + } + ]; + } + if (this.state.groupedBy.length > 1) { + var xlabels = [], + series = [], + label, serie, value; + values = {}; + for (var i = 0; i < this.state.data.length; i++) { + label = this.state.data[i].labels[0]; + serie = this.state.data[i].labels[1]; + value = this.state.data[i].value; + if ((!xlabels.length) || (xlabels[xlabels.length-1] !== label)) { + xlabels.push(label); + } + series.push(this.state.data[i].labels[1]); + if (!(serie in values)) {values[serie] = {};} + values[serie][label] = this.state.data[i].value; + } + series = _.uniq(series); + data = []; + var current_serie, j; + for (i = 0; i < series.length; i++) { + current_serie = {values: [], key: series[i]}; + for (j = 0; j < xlabels.length; j++) { + current_serie.values.push({ + x: xlabels[j], + y: values[series[i]][xlabels[j]] || 0, + }); + } + data.push(current_serie); + } + } + var svg = d3.select(this.$el[0]).append('svg'); + svg.datum(data); + + svg.transition().duration(0); + + var chart = nv.models.multiBarChart(); + chart.options({ + margin: {left: 120, bottom: 60}, + delay: 250, + transition: 10, + showLegend: _.size(data) <= MAX_LEGEND_LENGTH, + showXAxis: true, + showYAxis: true, + rightAlignYAxis: false, + stacked: this.stacked, + reduceXTicks: false, + rotateLabels: -20, + showControls: (this.state.groupedBy.length > 1) + }); + chart.yAxis.tickFormat(function (d) { + var measure_field = self.state.fields[self.measure]; + return formats.format_value(d, { + type: 'float', + digits: measure_field && measure_field.digits || [69, 2], + }); + }); + + chart(svg); + return chart; + }, + /** + * Helper function to set up data properly for the pieChart model in + * nvd3. + * + * @returns {nvd3 chart} + */ + _renderPieChart: function () { + var data = []; + var all_negative = true; + var some_negative = false; + var all_zero = true; + + this.state.data.forEach(function (datapt) { + all_negative = all_negative && (datapt.value < 0); + some_negative = some_negative || (datapt.value < 0); + all_zero = all_zero && (datapt.value === 0); + }); + if (some_negative && !all_negative) { + this.$el.append(qweb.render('GraphView.error', { + title: _t("Invalid data"), + description: _t("Pie chart cannot mix positive and negative numbers. " + + "Try to change your domain to only display positive results"), + })); + return; + } + if (all_zero) { + this.$el.append(qweb.render('GraphView.error', { + title: _t("Invalid data"), + description: _t("Pie chart cannot display all zero numbers.. " + + "Try to change your domain to display positive results"), + })); + return; + } + if (this.state.groupedBy.length) { + data = this.state.data.map(function (datapt) { + return {x:datapt.labels.join("/"), y: datapt.value}; + }); + } + var svg = d3.select(this.$el[0]).append('svg'); + svg.datum(data); + + svg.transition().duration(100); + + var legend_right = config.device.size_class > config.device.SIZES.XS; + + var chart = nv.models.pieChart().labelType('percent'); + chart.options({ + delay: 250, + showLegend: legend_right || _.size(data) <= MAX_LEGEND_LENGTH, + legendPosition: legend_right ? 'right' : 'top', + transition: 100, + color: d3.scale.category10().range(), + }); + + chart(svg); + return chart; + }, + /** + * Helper function to set up data properly for the line model in + * nvd3. + * + * @returns {nvd3 chart} + */ + _renderLineChart: function () { + if (this.state.data.length < 2) { + this.$el.append(qweb.render('GraphView.error', { + title: _t("Not enough data points"), + description: "You need at least two data points to display a line chart." + })); + return; + } + var self = this; + var data = []; + var tickValues; + var tickFormat; + var measure = this.state.fields[this.state.measure].string; + + if (this.state.groupedBy.length === 1) { + var values = this.state.data.map(function (datapt, index) { + return {x: index, y: datapt.value}; + }); + data = [ + { + values: values, + key: measure, + } + ]; + tickValues = this.state.data.map(function (d, i) { return i;}); + tickFormat = function (d) {return self.state.data[d].labels;}; + } + if (this.state.groupedBy.length > 1) { + data = []; + var data_dict = {}; + var tick = 0; + var tickLabels = []; + var serie, tickLabel; + var identity = function (p) {return p;}; + tickValues = []; + for (var i = 0; i < this.state.data.length; i++) { + if (this.state.data[i].labels[0] !== tickLabel) { + tickLabel = this.state.data[i].labels[0]; + tickValues.push(tick); + tickLabels.push(tickLabel); + tick++; + } + serie = this.state.data[i].labels[1]; + if (!data_dict[serie]) { + data_dict[serie] = { + values: [], + key: serie, + }; + } + data_dict[serie].values.push({ + x: tick, y: this.state.data[i].value, + }); + data = _.map(data_dict, identity); + } + tickFormat = function (d) {return tickLabels[d];}; + } + + var svg = d3.select(this.$el[0]).append('svg'); + svg.datum(data); + + svg.transition().duration(0); + + var chart = nv.models.lineChart(); + chart.options({ + margin: {left: 120, bottom: 60}, + useInteractiveGuideline: true, + showLegend: _.size(data) <= MAX_LEGEND_LENGTH, + showXAxis: true, + showYAxis: true, + }); + chart.xAxis.tickValues(tickValues) + .tickFormat(tickFormat); + chart.yAxis.tickFormat(function (d) { + return formats.format_value(d, { + type : 'float', + digits : self.state.fields[self.state.measure] && self.state.fields[self.state.measure].digits || [69, 2], + }); + }); + + chart(svg); + return chart; + }, +}); + +}); 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 00000000000..3422a0922d3 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -0,0 +1,69 @@ +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 GraphView = AbstractView.extend({ + display_name: _lt('Graph'), + icon: 'fa-bar-chart', + config: { + Model: GraphModel, + Controller: Controller, + Renderer: GraphRenderer, + }, + /** + * @override + */ + init: function (arch, fields) { + this._super.apply(this, arguments); + + var initialMeasure = '__count__'; + var initialGroupBys = []; + fields.__count__ = {string: _t("Count"), type: "integer"}; + arch.children.forEach(function (field) { + var name = field.attrs.name; + if (field.attrs.interval) { + name += ':' + field.attrs.interval; + } + if (field.attrs.type === 'measure') { + initialMeasure = name; + } else { + initialGroupBys.push(name); + } + }); + + var measures = {__count__: {string: _t("Count"), type: "integer"}}; + _.each(fields, function (field, name) { + if (name !== 'id' && field.store === true) { + if (field.type === 'integer' || field.type === 'float' || field.type === 'monetary') { + measures[name] = field; + } + } + }); + + this.controllerParams.measures = measures; + this.rendererParams.stacked = arch.attrs.stacked !== "False"; + + this.loadParams.initialMode = arch.attrs.type || 'bar'; + this.loadParams.initialMeasure = initialMeasure; + this.loadParams.initialGroupBys = initialGroupBys; + this.loadParams.fields = fields; + }, +}); + +return GraphView; + +}); |
