summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views/graph
diff options
context:
space:
mode:
authorGéry Debongnie <ged@odoo.com>2017-03-05 10:05:56 +0100
committerAaron Bohy <aab@odoo.com>2017-04-11 19:44:38 +0200
commit905e01921f3c3ef43ace6fc537d1d8c0d280002c (patch)
tree265c43ab21c5401a5d9f0f3d64d86f1ac417451c /addons/web/static/src/js/views/graph
parent54d4c73af4ad05c7640d28c1892247c9538b8017 (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.js128
-rw-r--r--addons/web/static/src/js/views/graph/graph_model.js188
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js334
-rw-r--r--addons/web/static/src/js/views/graph/graph_view.js69
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;
+
+});