5a4ff51f2d4d184e3cd7fd1063f20f8caaf15413
[odoo/odoo.git] / addons / web_graph / static / src / js / graph.js
1 /*---------------------------------------------------------
2  * OpenERP web_graph
3  *---------------------------------------------------------*/
4
5 /* jshint undef: false  */
6
7
8 openerp.web_graph = function (instance) {
9 'use strict';
10
11 var _lt = instance.web._lt;
12 var _t = instance.web._t;
13 var QWeb = instance.web.qweb;
14
15 instance.web.views.add('graph', 'instance.web_graph.GraphView');
16
17  /**
18   * GraphView view.  It mostly contains two widgets (PivotTable and ChartView)
19   * and some data.
20   */
21 instance.web_graph.GraphView = instance.web.View.extend({
22     template: 'GraphView',
23     display_name: _lt('Graph'),
24     view_type: 'graph',
25     mode: 'pivot',   // pivot => display pivot table, chart => display chart
26
27     events: {
28         'click .graph_mode_selection li' : function (event) {
29             event.preventDefault();
30             var view_mode = event.target.attributes['data-mode'].nodeValue;
31             if (view_mode === 'data') {
32                 this.mode = 'pivot';
33             } else {
34                 this.mode = 'chart';
35                 this.chart_view.set_mode(view_mode);
36             }
37             this.display_data();
38         },
39         'click .graph_clear_groups' : function (event) {
40             this.pivot_table.clear_groups();
41         },
42     },
43
44     view_loading: function (fields_view_get) {
45         var self = this;
46         var model = new instance.web.Model(fields_view_get.model, {group_by_no_leaf: true});
47         var options = {};
48         options.domain = [];
49         options.col_groupby = [];
50
51         // get the default groupbys and measure defined in the field view
52         options.measure = null;
53         options.row_groupby = [];
54         _.each(fields_view_get.arch.children, function (field) {
55             if ('name' in field.attrs) {
56                 if ('operator' in field.attrs) {
57                     options.measure = field.attrs.name;
58                 } else {
59                     options.row_groupby.push(field.attrs.name);
60                 }
61             }
62         });
63
64         // get the most important fields (of the model) by looking at the
65         // groupby filters defined in the search view
66         options.important_fields = [];
67         var load_view = instance.web.fields_view_get({
68             model: model,
69             view_type: 'search',
70         });
71
72         var important_fields_def = $.when(load_view).then(function (search_view) {
73             var groups = _.select(search_view.arch.children, function (c) {
74                 return (c.tag == 'group') && (c.attrs.string != 'Display');
75             });
76
77             _.each(groups, function(g) {
78                 _.each(g.children, function (g) {
79                     if (g.attrs.context) {
80                         var field_id = py.eval(g.attrs.context).group_by;
81                         options.important_fields.push(field_id);
82                     }
83                 });
84             });
85         });
86
87         // get the fields descriptions from the model
88         var field_descr_def = model.call('fields_get', [])
89             .then(function (fields) { options.fields = fields; });
90
91
92         return $.when(important_fields_def, field_descr_def)
93             .then(function () {
94                 self.pivot_table = new PivotTable(model, options);
95                 self.chart_view = new ChartView(model, options);
96             })
97             .then(function () {
98                 return self.pivot_table.appendTo('.graph_main_content');
99             })
100             .then(function() {
101                 return self.chart_view.appendTo('.graph_main_content');
102             });
103     },
104
105     display_data : function () {
106         if (this.mode === 'pivot') {
107             this.chart_view.hide();
108             this.pivot_table.show();
109         } else {
110             this.pivot_table.hide();
111             this.chart_view.show();
112         }
113     },
114
115     do_search: function (domain, context, group_by) {
116         this.domain = new instance.web.CompoundDomain(domain);
117         this.pivot_table.set_domain(domain);
118         this.chart_view.set_domain(domain);
119         this.display_data();
120     },
121
122     do_show: function () {
123         this.do_push_state({});
124         return this._super();
125     },
126
127 });
128
129  /**
130   * BasicDataView widget.  Basic widget to manage show/hide functionality
131   * and to initialize some attributes.  It is inherited by PivotTable 
132   * and ChartView widget.
133   */
134 var BasicDataView = instance.web.Widget.extend({
135
136     need_redraw: false,
137
138     // Input parameters: 
139     //      model: model to display
140     //      fields: dictionary returned by field_get on model (desc of model)
141     //      domain: constraints on model records 
142     //      row_groupby: groubys on rows (so, row headers in the pivot table)
143     //      col_groupby: idem, but on col
144     //      measure: quantity to display. either a field from the model, or 
145     //            null, in which case we use the "count" measure
146     init: function (model, options) {
147         console.log("initializing", model, options);
148         var self = this;
149         this.model = model;
150         this.fields = options.fields;
151         this.domain = options.domain;
152         this.groupby = {
153             row: options.row_groupby,
154             col: options.col_groupby,
155         };
156
157         this.measure = options.measure;
158         this.measure_label = options.measure ? options.fields[options.measure].string : 'Quantity';
159         this.data = [];
160         this.need_redraw = true;
161         this.important_fields = options.important_fields;
162     },
163
164     get_descr: function (field_id) {
165         return this.fields[field_id].string;
166     },
167
168     set_domain: function (domain) {
169         this.domain = domain;
170         this.need_redraw = true;
171     },
172
173     set_row_groupby: function (row_groupby) {
174         this.groupby.row = row_groupby;
175         this.need_redraw = true;
176     },
177
178     set_col_groupby: function (col_groupby) {
179         this.groupby.col = col_groupby;
180         this.need_redraw = true;
181     },
182
183     set_measure: function (measure) {
184         this.measure = measure;
185         this.need_redraw = true;
186     },
187
188     show: function () {
189         if (this.need_redraw) {
190             this.draw();
191             this.need_redraw = false;
192         }
193         this.$el.css('display', 'block');
194     },
195
196     hide: function () {
197         this.$el.css('display', 'none');
198     },
199
200     draw: function() {
201     },
202
203     get_data: function (groupby) {
204         var view_fields = this.groupby.row.concat(this.measure, this.groupby.col);
205         return query_groups(this.model, view_fields, this.domain, groupby);
206     },
207
208 });
209
210  /**
211   * PivotTable widget.  It displays the data in tabular data and allows the
212   * user to drill down and up in the table
213   */
214 var PivotTable = BasicDataView.extend({
215     template: 'pivot_table',
216     rows: [],
217     cols: [],
218     current_row_id : 0,
219
220     events: {
221         'click .graph_border > a' : function (event) {
222             var self = this;
223             event.preventDefault();
224             var row_id = event.target.attributes['data-row-id'].nodeValue;
225
226             var row = this.get_row(row_id);
227             if (row.expanded) {
228                 this.fold_row(row_id);
229             } else {
230                 if (row.path.length < this.groupby.row.length) {
231                     var field_to_expand = this.groupby.row[row.path.length];
232                     this.expand_row(row_id, field_to_expand);
233                 } else {
234                     var already_grouped = self.groupby.row.concat(self.groupby.col);
235                     var possible_groups = _.difference(self.important_fields, already_grouped);
236                     var dropdown_options = {
237                         fields: _.map(possible_groups, function (field) {
238                             return {id: field, value: self.get_descr(field)};
239                         }),
240                         row_id: row_id,
241                     };
242                     this.dropdown = $(QWeb.render('field_selection', dropdown_options));
243                     $(event.target).after(this.dropdown);
244                     $('.field-selection').next('.dropdown-menu').toggle();
245                 }
246             }
247
248     
249         },
250         'click a.field-selection' : function (event) {
251             event.preventDefault();
252             this.dropdown.remove();
253             var row_id = event.target.attributes['data-row-id'].nodeValue;
254             var field_id = event.target.attributes['data-field-id'].nodeValue;
255             this.expand_row(row_id, field_id);
256         },
257     },
258
259     clear_groups: function () {
260         this.groupby.row = [];
261         this.groupby.col = [];
262         this.rows = [];
263         this.cols = [];
264         this.draw();
265     },
266
267     init: function (model, options) {
268         this._super(model, options);
269     },
270
271     generate_id: function () {
272         this.current_row_id += 1;
273         return this.current_row_id - 1;
274     },
275
276     get_row: function (id) {
277         return _.find(this.rows, function(row) {
278             return (row.id == id);
279         });
280     },
281
282     make_cell: function (content, options) {
283         var attrs = ['<td'];
284         if (options && options.is_border) {
285             attrs.push('class="graph_border"');
286         }
287
288         attrs.push('>');
289         if (options && options.indent) {
290             _.each(_.range(options.indent), function () {
291                 attrs.push('<span class="web_graph_indent"></span>');
292             });
293         }
294         if (options && options.foldable) {
295             attrs.push('<a data-row-id="'+ options.row_id + '" href="#" class="icon-plus-sign"> </a>');
296         }
297         return attrs.join(' ') + content + '</td>';
298     },
299
300     make_row: function (data, parent_id) {
301         var has_parent = (parent_id !== undefined);
302         var parent = has_parent ? this.get_row(parent_id) : null;
303         var path;
304         if (has_parent) {
305             path = parent.path.concat(data.attributes.grouped_on);
306         } else if (data.attributes.grouped_on !== undefined) {
307             path = [data.attributes.grouped_on];
308         } else {
309             path = [];
310         }
311
312         var indent_level = has_parent ? parent.path.length : 0;
313         var value = (this.groupby.row.length > 0) ? data.attributes.value[1] : 'Total';
314
315
316         var jquery_row = $('<tr></tr>');
317         var row_id = this.generate_id();
318
319         var header = $(this.make_cell(value, {is_border:true, indent: indent_level, foldable:true, row_id: row_id}));
320         jquery_row.html(header);
321         jquery_row.append(this.make_cell(data.attributes.aggregates[this.measure]));
322
323         var row = {
324             id: row_id,
325             path: path,
326             value: value,
327             expanded: false,
328             parent: parent_id,
329             children: [],
330             html_tr: jquery_row,
331             domain: data.model._domain,
332         };
333         // rows.splice(index of parent if any,0,row);
334         this.rows.push(row);  // to do, insert it properly
335
336         if (this.groupby.row.length === 0) {
337             row.remove_when_expanded = true;
338             row.domain = this.domain;
339         }
340         if (has_parent) {
341             parent.children.push(row.id);
342         }
343         return row;
344     },
345
346     expand_row: function (row_id, field_id) {
347         var self = this;
348         var row = this.get_row(row_id);
349
350         if (row.path.length == this.groupby.row.length) {
351             this.groupby.row.push(field_id);
352         }
353
354         var visible_fields = this.groupby.row.concat(this.groupby.col, this.measure);
355
356         if (row.remove_when_expanded) {
357             this.rows = [];
358         } else {
359             row.expanded = true;
360             row.html_tr.find('.icon-plus-sign')
361                 .removeClass('icon-plus-sign')
362                 .addClass('icon-minus-sign');
363         }
364
365         query_groups(this.model, visible_fields, row.domain, [field_id])
366             .then(function (data) {
367                 _.each(data.reverse(), function (datapt) {
368                     var new_row;
369                     if (row.remove_when_expanded) {
370                         new_row = self.make_row(datapt);
371                         self.$('tr.graph_table_header').after(new_row.html_tr);
372                     } else {
373                         new_row = self.make_row(datapt, row_id);
374                         row.html_tr.after(new_row.html_tr);
375                     }
376                 });
377                 if (row.remove_when_expanded) {
378                     row.html_tr.remove();
379                 }
380         });
381
382     },
383
384     fold_row: function (row_id) {
385         var self = this;
386         var row = this.get_row(row_id);
387
388         _.each(row.children, function (child_row) {
389             self.remove_row(child_row);
390         });
391         row.children = [];
392
393         row.expanded = false;
394         row.html_tr.find('.icon-minus-sign')
395             .removeClass('icon-minus-sign')
396             .addClass('icon-plus-sign');
397
398         var fold_levels = _.map(self.rows, function(g) {return g.path.length;});
399         var new_groupby_length = _.reduce(fold_levels, function (x, y) {
400             return Math.max(x,y);
401         }, 0);
402
403         this.groupby.row.splice(new_groupby_length);
404     },
405
406     remove_row: function (row_id) {
407         var self = this;
408         var row = this.get_row(row_id);
409
410         _.each(row.children, function (child_row) {
411             self.remove_row(child_row);
412         });
413
414         row.html_tr.remove();
415         removeFromArray(this.rows, row);
416     },
417
418     draw: function () {
419         this.get_data(this.groupby.row)
420             .then(this.proxy('build_table'))
421             .done(this.proxy('_draw'));
422     },
423
424     build_table: function (data) {
425         var self = this;
426         this.rows = [];
427
428         this.cols = [{
429             path: [],
430             value: this.measure_label,
431             expanded: false,
432             parent: null,
433             children: [],
434             html_tds: [],
435             domain: this.domain,
436             header: $(this.make_cell(this.measure_label, {is_border:true})),
437         }];
438
439         _.each(data, function (datapt) {
440             self.make_row(datapt);
441         });
442     },
443
444     _draw: function () {
445
446         this.$el.empty();
447         var self = this;
448         var header;
449
450         if (this.groupby.row.length > 0) {
451             header = '<tr><td class="graph_border">' +
452                     this.fields[this.groupby.row[0]].string +
453                     '</td><td class="graph_border">' +
454                     this.measure_label +
455                     '</td></tr>';
456         } else {
457             header = '<tr class="graph_table_header"><td class="graph_border">' +
458                     '</td><td class="graph_border">' +
459                     this.measure_label +
460                     '</td></tr>';
461         }
462         this.$el.append(header);
463
464         _.each(this.rows, function (row) {
465             self.$el.append(row.html_tr);
466         });
467
468     }
469 });
470
471  /**
472   * ChartView widget.  It displays the data in chart form, using the nvd3
473   * library.  Various modes include bar charts, pie charts or line charts.
474   */
475 var ChartView = BasicDataView.extend({
476     template: 'chart_view',
477
478     set_mode: function (mode) {
479         this.render = this['render_' + mode];
480         this.need_redraw = true;
481     },
482
483     draw: function () {
484         var self = this;
485         this.$el.empty();
486         this.$el.append('<svg></svg>');
487         this.get_data(this.groupby.row).done(function (data) {
488             self.render(data);
489         });
490     },
491
492     format_data:  function (datapt) {
493         var val = datapt.attributes;
494         return {
495             x: datapt.attributes.value[1],
496             y: this.measure ? val.aggregates[this.measure] : val.length,
497         };
498     },
499
500     render_bar_chart: function (data) {
501         var formatted_data = [{
502                 key: 'Bar chart',
503                 values: _.map(data, this.proxy('format_data')),
504             }];
505
506         nv.addGraph(function () {
507             var chart = nv.models.discreteBarChart()
508                 .tooltips(false)
509                 .showValues(true)
510                 .staggerLabels(true)
511                 .width(650)
512                 .height(400);
513
514             d3.select('.graph_chart svg')
515                 .datum(formatted_data)
516                 .attr('width', 650)
517                 .attr('height', 400)
518                 .call(chart);
519
520             nv.utils.windowResize(chart.update);
521             return chart;
522         });
523     },
524
525     render_line_chart: function (data) {
526         var formatted_data = [{
527                 key: this.measure_label,
528                 values: _.map(data, this.proxy('format_data'))
529             }];
530
531         nv.addGraph(function () {
532             var chart = nv.models.lineChart()
533                 .x(function (d,u) { return u; })
534                 .width(600)
535                 .height(300)
536                 .margin({top: 30, right: 20, bottom: 20, left: 60});
537
538             d3.select('.graph_chart svg')
539                 .attr('width', 600)
540                 .attr('height', 300)
541                 .datum(formatted_data)
542                 .call(chart);
543
544             return chart;
545           });
546     },
547
548     render_pie_chart: function (data) {
549         var formatted_data = _.map(data, this.proxy('format_data'));
550
551         nv.addGraph(function () {
552             var chart = nv.models.pieChart()
553                 .color(d3.scale.category10().range())
554                 .width(650)
555                 .height(400);
556
557             d3.select('.graph_chart svg')
558                 .datum(formatted_data)
559                 .transition().duration(1200)
560                 .attr('width', 650)
561                 .attr('height', 400)
562                 .call(chart);
563
564             nv.utils.windowResize(chart.update);
565             return chart;
566         });
567     },
568
569 });
570
571 // utility
572 function removeFromArray(array, element) {
573     var index = array.indexOf(element);
574     if (index > -1) {
575         array.splice(index, 1);
576     }
577 }
578
579 /**
580  * Query the server and return a deferred which will return the data
581  * with all the groupbys applied (this is done for now, but the goal
582  * is to modify read_group in order to allow eager and lazy groupbys
583  */
584 function query_groups (model, fields, domain, groupbys) {
585     return model.query(fields)
586         .filter(domain)
587         .group_by(groupbys)
588         .then(function (results) {
589             var non_empty_results = _.filter(results, function (group) {
590                 return group.attributes.length > 0;
591             });
592             if (groupbys.length <= 1) {
593                 return non_empty_results;
594             } else {
595                 var get_subgroups = $.when.apply(null, _.map(non_empty_results, function (result) {
596                     var new_domain = result.model._domain;
597                     var new_groupings = groupbys.slice(1);
598                     return query_groups(model, fields,new_domain, new_groupings).then(function (subgroups) {
599                         result.subgroups_data = subgroups;
600                     });
601                 }));
602                 return get_subgroups.then(function () {
603                     return non_empty_results;
604                 });
605             }
606         });
607 }
608
609
610 };
611