[IMP] simplify list-wise aggregation computations, improve sub-grouping so sub-groups...
[odoo/odoo.git] / addons / base / static / src / js / list.js
1 openerp.base.list = function (openerp) {
2 openerp.base.views.add('list', 'openerp.base.ListView');
3 openerp.base.ListView = openerp.base.View.extend( /** @lends openerp.base.ListView# */ {
4     defaults: {
5         // records can be selected one by one
6         'selectable': true,
7         // list rows can be deleted
8         'deletable': true,
9         // whether the column headers should be displayed
10         'header': true,
11         // display addition button, with that label
12         'addable': "New",
13         // whether the list view can be sorted, note that once a view has been
14         // sorted it can not be reordered anymore
15         'sortable': true,
16         // whether the view rows can be reordered (via vertical drag & drop)
17         'reorderable': true
18     },
19     /**
20      * Core class for list-type displays.
21      *
22      * As a view, needs a number of view-related parameters to be correctly
23      * instantiated, provides options and overridable methods for behavioral
24      * customization.
25      *
26      * See constructor parameters and method documentations for information on
27      * the default behaviors and possible options for the list view.
28      *
29      * @constructs
30      * @param view_manager
31      * @param session An OpenERP session object
32      * @param element_id the id of the DOM elements this view should link itself to
33      * @param {openerp.base.DataSet} dataset the dataset the view should work with
34      * @param {String} view_id the listview's identifier, if any
35      * @param {Object} options A set of options used to configure the view
36      * @param {Boolean} [options.selectable=true] determines whether view rows are selectable (e.g. via a checkbox)
37      * @param {Boolean} [options.header=true] should the list's header be displayed
38      * @param {Boolean} [options.deletable=true] are the list rows deletable
39      * @param {null|String} [options.addable="New"] should the new-record button be displayed, and what should its label be. Use ``null`` to hide the button.
40      * @param {Boolean} [options.sortable=true] is it possible to sort the table by clicking on column headers
41      * @param {Boolean} [options.reorderable=true] is it possible to reorder list rows
42      *
43      * @borrows openerp.base.ActionExecutor#execute_action as #execute_action
44      */
45     init: function(view_manager, session, element_id, dataset, view_id, options) {
46         this._super(session, element_id);
47         this.view_manager = view_manager || new openerp.base.NullViewManager();
48         this.dataset = dataset;
49         this.model = dataset.model;
50         this.view_id = view_id;
51
52         this.columns = [];
53
54         this.options = _.extend({}, this.defaults, options || {});
55         this.flags =  this.view_manager.flags || {};
56
57         this.set_groups(new openerp.base.ListView.Groups(this));
58         
59         if (this.dataset instanceof openerp.base.DataSetStatic) {
60             this.groups.datagroup = new openerp.base.StaticDataGroup(this.dataset);
61         }
62     },
63     /**
64      * Set a custom Group construct as the root of the List View.
65      *
66      * @param {openerp.base.ListView.Groups} groups
67      */
68     set_groups: function (groups) {
69         var self = this;
70         if (this.groups) {
71             $(this.groups).unbind("selected deleted action row_link");
72             delete this.groups;
73         }
74
75         this.groups = groups;
76         $(this.groups).bind({
77             'selected': function (e, ids, records) {
78                 self.do_select(ids, records);
79             },
80             'deleted': function (e, ids) {
81                 self.do_delete(ids);
82             },
83             'action': function (e, action_name, id, callback) {
84                 self.do_action(action_name, id, callback);
85             },
86             'row_link': function (e, id, dataset) {
87                 self.do_activate_record(dataset.index, id, dataset);
88             }
89         });
90     },
91     /**
92      * View startup method, the default behavior is to set the ``oe-listview``
93      * class on its root element and to perform an RPC load call.
94      *
95      * @returns {$.Deferred} loading promise
96      */
97     start: function() {
98         this.$element.addClass('oe-listview');
99         return this.reload_view();
100     },
101     /**
102      * Called after loading the list view's description, sets up such things
103      * as the view table's columns, renders the table itself and hooks up the
104      * various table-level and row-level DOM events (action buttons, deletion
105      * buttons, selection of records, [New] button, selection of a given
106      * record, ...)
107      *
108      * Sets up the following:
109      *
110      * * Processes arch and fields to generate a complete field descriptor for each field
111      * * Create the table itself and allocate visible columns
112      * * Hook in the top-level (header) [New|Add] and [Delete] button
113      * * Sets up showing/hiding the top-level [Delete] button based on records being selected or not
114      * * Sets up event handlers for action buttons and per-row deletion button
115      * * Hooks global callback for clicking on a row
116      * * Sets up its sidebar, if any
117      *
118      * @param {Object} data wrapped fields_view_get result
119      * @param {Object} data.fields_view fields_view_get result (processed)
120      * @param {Object} data.fields_view.fields mapping of fields for the current model
121      * @param {Object} data.fields_view.arch current list view descriptor
122      * @param {Boolean} grouped Is the list view grouped
123      */
124     on_loaded: function(data, grouped) {
125         var self = this;
126         this.fields_view = data.fields_view;
127         //this.log(this.fields_view);
128         this.name = "" + this.fields_view.arch.attrs.string;
129
130         this.setup_columns(this.fields_view.fields, grouped);
131
132         if (!this.fields_view.sorted) { this.fields_view.sorted = {}; }
133
134         this.$element.html(QWeb.render("ListView", this));
135
136         // Head hook
137         this.$element.find('#oe-list-add')
138                 .click(this.do_add_record)
139                 .attr('disabled', grouped && this.options.editable);
140         this.$element.find('#oe-list-delete')
141                 .attr('disabled', true)
142                 .click(this.do_delete_selected);
143         this.$element.find('thead').delegate('th[data-id]', 'click', function (e) {
144             e.stopPropagation();
145
146             self.dataset.sort($(this).data('id'));
147
148             // TODO: should only reload content (and set the right column to a sorted display state)
149             self.reload_view();
150         });
151
152         this.view_manager.sidebar.set_toolbar(data.fields_view.toolbar);
153     },
154     /**
155      * Sets up the listview's columns: merges view and fields data, move
156      * grouped-by columns to the front of the columns list and make them all
157      * visible.
158      *
159      * @param {Object} fields fields_view_get's fields section
160      * @param {Boolean} [grouped] Should the grouping columns (group and count) be displayed
161      */
162     setup_columns: function (fields, grouped) {
163         var domain_computer = openerp.base.form.compute_domain;
164
165         var noop = function () { return {}; };
166         var field_to_column = function (field) {
167             var name = field.attrs.name;
168             var column = _.extend({id: name, tag: field.tag},
169                     field.attrs, fields[name]);
170             // attrs computer
171             if (column.attrs) {
172                 var attrs = JSON.parse(column.attrs);
173                 column.attrs_for = function (fields) {
174                     var result = {};
175                     for (var attr in attrs) {
176                         result[attr] = domain_computer(attrs[attr], fields);
177                     }
178                     return result;
179                 };
180             } else {
181                 column.attrs_for = noop;
182             }
183             return column;
184         };
185         
186         this.columns.splice(0, this.columns.length);
187         this.columns.push.apply(
188                 this.columns,
189                 _(this.fields_view.arch.children).map(field_to_column));
190         if (grouped) {
191             this.columns.unshift({
192                 id: '_group', tag: '', string: "Group", meta: true,
193                 attrs_for: function () { return {}; }
194             }, {
195                 id: '_count', tag: '', string: '#', meta: true,
196                 attrs_for: function () { return {}; }
197             });
198         }
199
200         this.visible_columns = _.filter(this.columns, function (column) {
201             return column.invisible !== '1';
202         });
203
204         this.aggregate_columns = _(this.visible_columns)
205             .map(function (column) {
206                 if (column.type !== 'integer' && column.type !== 'float') {
207                     return {};
208                 }
209                 var aggregation_func = column['group_operator'] || 'sum';
210
211                 if (!column[aggregation_func]) {
212                     return {};
213                 }
214
215                 return {
216                     field: column.id,
217                     type: column.type,
218                     'function': aggregation_func,
219                     label: column[aggregation_func]
220                 };
221             });
222     },
223     /**
224      * Used to handle a click on a table row, if no other handler caught the
225      * event.
226      *
227      * The default implementation asks the list view's view manager to switch
228      * to a different view (by calling
229      * :js:func:`~openerp.base.ViewManager.on_mode_switch`), using the
230      * provided record index (within the current list view's dataset).
231      *
232      * If the index is null, ``switch_to_record`` asks for the creation of a
233      * new record.
234      *
235      * @param {Number|null} index the record index (in the current dataset) to switch to
236      * @param {String} [view="form"] the view type to switch to
237      */
238     select_record:function (index, view) {
239         view = view || 'form';
240         this.dataset.index = index;
241         _.delay(_.bind(function () {
242             if(this.view_manager) {
243                 this.view_manager.on_mode_switch(view);
244             }
245         }, this));
246     },
247     do_show: function () {
248         this.$element.show();
249         if (this.hidden) {
250             this.$element.find('table').append(
251                 this.groups.apoptosis().render());
252             this.hidden = false;
253         }
254         this.view_manager.sidebar.do_refresh(true);
255     },
256     do_hide: function () {
257         this.$element.hide();
258         this.hidden = true;
259     },
260     /**
261      * Reloads the list view based on the current settings (dataset & al)
262      *
263      * @param {Boolean} [grouped] Should the list be displayed grouped
264      */
265     reload_view: function (grouped) {
266         var self = this;
267         this.dataset.offset = 0;
268         this.dataset.limit = false;
269         var callback = function (field_view_get) {
270                 self.on_loaded(field_view_get, grouped);
271         };
272         if (this.embedded_view) {
273             return $.Deferred().then(callback).resolve({fields_view: this.embedded_view});
274         } else {
275             return this.rpc('/base/listview/load', {
276                 model: this.model,
277                 view_id: this.view_id,
278                 context: this.dataset.context,
279                 toolbar: !!this.flags.sidebar
280             }, callback);
281         }
282     },
283     /**
284      * re-renders the content of the list view
285      */
286     reload_content: function () {
287         this.$element.find('table').append(
288             this.groups.apoptosis().render(
289                 $.proxy(this, 'compute_aggregates')));
290     },
291     /**
292      * Event handler for a search, asks for the computation/folding of domains
293      * and contexts (and group-by), then reloads the view's content.
294      *
295      * @param {Array} domains a sequence of literal and non-literal domains
296      * @param {Array} contexts a sequence of literal and non-literal contexts
297      * @param {Array} groupbys a sequence of literal and non-literal group-by contexts
298      * @returns {$.Deferred} fold request evaluation promise
299      */
300     do_search: function (domains, contexts, groupbys) {
301         return this.rpc('/base/session/eval_domain_and_context', {
302             domains: domains,
303             contexts: contexts,
304             group_by_seq: groupbys
305         }, $.proxy(this, 'do_actual_search'));
306     },
307     /**
308      * Handler for the result of eval_domain_and_context, actually perform the
309      * searching
310      *
311      * @param {Object} results results of evaluating domain and process for a search
312      */
313     do_actual_search: function (results) {
314         this.dataset.context = results.context;
315         this.dataset.domain = results.domain;
316         this.groups.datagroup = new openerp.base.DataGroup(
317             this.session, this.model,
318             results.domain, results.context,
319             results.group_by);
320
321         if (_.isEmpty(results.group_by) && !results.context['group_by_no_leaf']) {
322             results.group_by = null;
323         }
324
325         this.reload_view(!!results.group_by).then(
326             $.proxy(this, 'reload_content'));
327     },
328     /**
329      * Handles the signal to delete a line from the DOM
330      *
331      * @param {Array} ids the id of the object to delete
332      */
333     do_delete: function (ids) {
334         if (!ids.length) {
335             return;
336         }
337         var self = this;
338         return $.when(this.dataset.unlink(ids)).then(function () {
339             _(self.rows).chain()
340                 .map(function (row, index) {
341                     return {
342                         index: index,
343                         id: row.data.id.value
344                     };})
345                 .filter(function (record) {
346                     return _.contains(ids, record.id);
347                 })
348                 .sort(function (a, b) {
349                     // sort in reverse index order, so we delete from the end
350                     // and don't blow up the following indexes (leading to
351                     // removing the wrong records from the visible list)
352                     return b.index - a.index;
353                 })
354                 .each(function (record) {
355                     self.rows.splice(record.index, 1);
356                 });
357             // TODO only refresh modified rows
358         });
359     },
360     /**
361      * Handles the signal indicating that a new record has been selected
362      *
363      * @param {Array} ids selected record ids
364      * @param {Array} records selected record values
365      */
366     do_select: function (ids, records) {
367         this.$element.find('#oe-list-delete')
368             .attr('disabled', !ids.length);
369
370         if (!records.length) {
371             this.compute_aggregates();
372             return;
373         }
374         this.compute_aggregates(records);
375     },
376     /**
377      * Handles action button signals on a record
378      *
379      * @param {String} name action name
380      * @param {Object} id id of the record the action should be called on
381      * @param {Function} callback should be called after the action is executed, if non-null
382      */
383     do_action: function (name, id, callback) {
384         var action = _.detect(this.columns, function (field) {
385             return field.name === name;
386         });
387         if (!action) { return; }
388         this.execute_action(
389             action, this.dataset, this.session.action_manager,
390             id, function () {
391                 if (callback) {
392                     callback();
393                 }
394         });
395     },
396     /**
397      * Handles the activation of a record (clicking on it)
398      *
399      * @param {Number} index index of the record in the dataset
400      * @param {Object} id identifier of the activated record
401      * @param {openerp.base.DataSet} dataset dataset in which the record is available (may not be the listview's dataset in case of nested groups)
402      */
403     do_activate_record: function (index, id, dataset) {
404         var self = this;
405         _.extend(this.dataset, {
406             domain: dataset.domain,
407             context: dataset.context
408         }).read_slice([], 0, false, function () {
409             self.select_record(index);
410         });
411     },
412     /**
413      * Handles signal for the addition of a new record (can be a creation,
414      * can be the addition from a remote source, ...)
415      *
416      * The default implementation is to switch to a new record on the form view
417      */
418     do_add_record: function () {
419         this.select_record(null);
420     },
421     /**
422      * Handles deletion of all selected lines
423      */
424     do_delete_selected: function () {
425         this.do_delete(this.groups.get_selection().ids);
426     },
427     /**
428      * Computes the aggregates for the current list view, either on the
429      * records provided or on the records of the internal
430      * :js:class:`~openerp.base.ListView.Group`, by calling
431      * :js:func:`~openerp.base.ListView.group.get_records`.
432      *
433      * Then displays the aggregates in the table through
434      * :js:method:`~openerp.base.ListView.display_aggregates`.
435      *
436      * @param {Array} [records]
437      */
438     compute_aggregates: function (records) {
439         var columns = _(this.aggregate_columns).filter(function (column) {
440             return column['function']; });
441
442         if (_.isEmpty(columns)) { return; }
443
444         if (_.isEmpty(records)) {
445             records = this.groups.get_records();
446         }
447
448         var count = 0, sums = {};
449         _(columns).each(function (column) { sums[column.field] = 0; });
450         _(records).each(function (record) {
451             count += 1; //(record.count || 1);
452             _(columns).each(function (column) {
453                 sums[column.field] += record[column.field];
454             });
455         });
456
457         var aggregates = {};
458         _(columns).each(function (column) {
459             var field = column.field;
460             switch (column['function']) {
461                 case 'sum':
462                     aggregates[field] = sums[field];
463                     break;
464                 case 'avg':
465                     aggregates[field] = sums[field] / count;
466                     break;
467             }
468         });
469
470         this.display_aggregates(aggregates);
471     },
472     display_aggregates: function (aggregation) {
473         var $footer_cells = this.$element.find('.oe-list-footer');
474         _(this.aggregate_columns).each(function (column) {
475             if (!column['function']) {
476                 return;
477             }
478             var pattern = (column.type == 'integer') ? '%d' : '%.2f';
479             $footer_cells.filter(_.sprintf('[data-field=%s]', column.field))
480                 .text(_.sprintf(pattern, aggregation[column.field]));
481         });
482     }
483     // TODO: implement reorder (drag and drop rows)
484 });
485 openerp.base.ListView.List = Class.extend( /** @lends openerp.base.ListView.List# */{
486     /**
487      * List display for the ListView, handles basic DOM events and transforms
488      * them in the relevant higher-level events, to which the list view (or
489      * other consumers) can subscribe.
490      *
491      * Events on this object are registered via jQuery.
492      *
493      * Available events:
494      *
495      * `selected`
496      *   Triggered when a row is selected (using check boxes), provides an
497      *   array of ids of all the selected records.
498      * `deleted`
499      *   Triggered when deletion buttons are hit, provide an array of ids of
500      *   all the records being marked for suppression.
501      * `action`
502      *   Triggered when an action button is clicked, provides two parameters:
503      *
504      *   * The name of the action to execute (as a string)
505      *   * The id of the record to execute the action on
506      * `row_link`
507      *   Triggered when a row of the table is clicked, provides the index (in
508      *   the rows array) and id of the selected record to the handle function.
509      *
510      * @constructs
511      * @param {Object} opts display options, identical to those of :js:class:`openerp.base.ListView`
512      */
513     init: function (group, opts) {
514         var self = this;
515         this.group = group;
516
517         this.options = opts.options;
518         this.columns = opts.columns;
519         this.dataset = opts.dataset;
520         this.rows = opts.rows;
521
522         this.$_element = $('<tbody class="ui-widget-content">')
523             .appendTo(document.body)
524             .delegate('th.oe-record-selector', 'click', function (e) {
525                 e.stopPropagation();
526                 var selection = self.get_selection();
527                 $(self).trigger(
528                         'selected', [selection.ids, selection.records]);
529             })
530             .delegate('td.oe-record-delete button', 'click', function (e) {
531                 e.stopPropagation();
532                 var $row = $(e.target).closest('tr');
533                 $(self).trigger('deleted', [[self.row_id($row)]]);
534             })
535             .delegate('td.oe-field-cell button', 'click', function (e) {
536                 e.stopPropagation();
537                 var $target = $(e.currentTarget),
538                       field = $target.closest('td').data('field'),
539                        $row = $target.closest('tr'),
540                   record_id = self.row_id($row),
541                       index = self.row_position($row);
542
543                 $(self).trigger('action', [field, record_id, function () {
544                     self.reload_record(index, true);
545                 }]);
546             })
547             .delegate('tr', 'click', function (e) {
548                 e.stopPropagation();
549                 self.dataset.index = self.row_position(e.currentTarget);
550                 self.row_clicked(e);
551             });
552     },
553     row_clicked: function () {
554         $(this).trigger(
555             'row_link',
556             [this.rows[this.dataset.index].data.id.value,
557              this.dataset]);
558     },
559     render: function () {
560         if (this.$current) {
561             this.$current.remove();
562         }
563         this.$current = this.$_element.clone(true);
564         this.$current.empty().append($(QWeb.render('ListView.rows', this)));
565     },
566     get_fields_view: function () {
567         // deep copy of view
568         var view = $.extend(true, {}, this.group.view.fields_view);
569         _(view.arch.children).each(function (widget) {
570             widget.attrs.nolabel = true;
571             if (widget.tag === 'button') {
572                 delete widget.attrs.string;
573             }
574         });
575         view.arch.attrs.col = 2 * view.arch.children.length;
576         return view;
577     },
578     /**
579      * Gets the ids of all currently selected records, if any
580      * @returns {Object} object with the keys ``ids`` and ``records``, holding respectively the ids of all selected records and the records themselves.
581      */
582     get_selection: function () {
583         if (!this.options.selectable) {
584             return [];
585         }
586         var rows = this.rows;
587         var result = {ids: [], records: []};
588         this.$current.find('th.oe-record-selector input:checked')
589                 .closest('tr').each(function () {
590             var record = {};
591             _(rows[$(this).data('index')].data).each(function (obj, key) {
592                 record[key] = obj.value;
593             });
594             result.ids.push(record.id);
595             result.records.push(record);
596         });
597         return result;
598     },
599     /**
600      * Returns the index of the row in the list of rows.
601      *
602      * @param {Object} row the selected row
603      * @returns {Number} the position of the row in this.rows
604      */
605     row_position: function (row) {
606         return $(row).data('index');
607     },
608     /**
609      * Returns the identifier of the object displayed in the provided table
610      * row
611      *
612      * @param {Object} row the selected table row
613      * @returns {Number|String} the identifier of the row's object
614      */
615     row_id: function (row) {
616         return this.rows[this.row_position(row)].data.id.value;
617     },
618     /**
619      * Death signal, cleans up list
620      */
621     apoptosis: function () {
622         if (!this.$current) { return; }
623         this.$current.remove();
624         this.$current = null;
625         this.$_element.remove();
626     },
627     get_records: function () {
628         return _(this.rows).map(function (row) {
629             var record = {};
630             _(row.data).each(function (obj, key) {
631                 record[key] = obj.value;
632             });
633             return record;
634         });
635     },
636     /**
637      * Transforms a record from what is returned by a dataset read (a simple
638      * mapping of ``$fieldname: $value``) to the format expected by list rows
639      * and form views:
640      *
641      *    data: {
642      *         $fieldname: {
643      *             value: $value
644      *         }
645      *     }
646      *
647      * This format allows for the insertion of a bunch of metadata (names,
648      * colors, etc...)
649      *
650      * @param {Object} record original record, in dataset format
651      * @returns {Object} record displayable in a form or list view
652      */
653     transform_record: function (record) {
654         // TODO: colors handling
655         var form_data = {},
656           form_record = {data: form_data};
657
658         _(record).each(function (value, key) {
659             form_data[key] = {value: value};
660         });
661
662         return form_record;
663     },
664     /**
665      * Reloads the record at index ``row_index`` in the list's rows.
666      *
667      * By default, simply re-renders the record. If the ``fetch`` parameter is
668      * provided and ``true``, will first fetch the record anew.
669      *
670      * @param {Number} record_index index of the record to reload
671      * @param {Boolean} fetch fetches the record from remote before reloading it
672      */
673     reload_record: function (record_index, fetch) {
674         var self = this;
675         var read_p = null;
676         if (fetch) {
677             // save index to restore it later, if already set
678             var old_index = this.dataset.index;
679             this.dataset.index = record_index;
680             read_p = this.dataset.read_index(
681                 _.filter(_.pluck(this.columns, 'name'), _.identity),
682                 function (record) {
683                     var form_record = self.transform_record(record);
684                     self.rows.splice(record_index, 1, form_record);
685                     self.dataset.index = old_index;
686                 }
687             )
688         }
689
690         return $.when(read_p).then(function () {
691             self.$current.children().eq(record_index)
692                 .replaceWith(self.render_record(record_index)); })
693     },
694     /**
695      * Renders a list record to HTML
696      *
697      * @param {Number} record_index index of the record to render in ``this.rows``
698      * @returns {String} QWeb rendering of the selected record
699      */
700     render_record: function (record_index) {
701         return QWeb.render('ListView.row', {
702             columns: this.columns,
703             options: this.options,
704             row: this.rows[record_index],
705             row_parity: (record_index % 2 === 0) ? 'even' : 'odd',
706             row_index: record_index
707         });
708     }
709     // drag and drop
710 });
711 openerp.base.ListView.Groups = Class.extend( /** @lends openerp.base.ListView.Groups# */{
712     passtrough_events: 'action deleted row_link',
713     /**
714      * Grouped display for the ListView. Handles basic DOM events and interacts
715      * with the :js:class:`~openerp.base.DataGroup` bound to it.
716      *
717      * Provides events similar to those of
718      * :js:class:`~openerp.base.ListView.List`
719      */
720     init: function (view) {
721         this.view = view;
722         this.options = view.options;
723         this.columns = view.columns;
724         this.datagroup = null;
725
726         this.sections = [];
727         this.children = {};
728     },
729     pad: function ($row) {
730         if (this.options.selectable) {
731             $row.append('<td>');
732         }
733     },
734     make_fragment: function () {
735         return document.createDocumentFragment();
736     },
737     /**
738      * Returns a DOM node after which a new tbody can be inserted, so that it
739      * follows the provided row.
740      *
741      * Necessary to insert the result of a new group or list view within an
742      * existing groups render, without losing track of the groups's own
743      * elements
744      *
745      * @param {HTMLTableRowElement} row the row after which the caller wants to insert a body
746      * @returns {HTMLTableSectionElement} element after which a tbody can be inserted
747      */
748     point_insertion: function (row) {
749         var $row = $(row);
750         var red_letter_tboday = $row.closest('tbody')[0];
751
752         var $next_siblings = $row.nextAll();
753         if ($next_siblings.length) {
754             var $root_kanal = $('<tbody>').insertAfter(red_letter_tboday);
755
756             $root_kanal.append($next_siblings);
757             this.elements.splice(
758                 _.indexOf(this.elements, red_letter_tboday),
759                 0,
760                 $root_kanal[0]);
761         }
762         return red_letter_tboday;
763     },
764     open: function (point_insertion) {
765         this.render().insertAfter(point_insertion);
766     },
767     close: function () {
768         this.apoptosis();
769     },
770     /**
771      * Prefixes ``$node`` with floated spaces in order to indent it relative
772      * to its own left margin/baseline
773      *
774      * @param {jQuery} $node jQuery object to indent
775      * @param {Number} level current nesting level, >= 1
776      * @returns {jQuery} the indentation node created
777      */
778     indent: function ($node, level) {
779         return $('<span>')
780                 .css({'float': 'left', 'white-space': 'pre'})
781                 .text(new Array(level).join('   '))
782                 .prependTo($node);
783     },
784     render_groups: function (datagroups) {
785         var self = this;
786         var placeholder = this.make_fragment();
787         _(datagroups).each(function (group) {
788             if (self.children[group.value]) {
789                 self.children[group.value].apoptosis();
790                 delete self.children[group.value];
791             }
792             var child = self.children[group.value] = new openerp.base.ListView.Groups(self.view, {
793                 options: self.options,
794                 columns: self.columns
795             });
796             self.bind_child_events(child);
797             child.datagroup = group;
798
799             var $row = $('<tr>');
800             if (group.openable) {
801                 $row.click(function (e) {
802                     if (!$row.data('open')) {
803                         $row.data('open', true)
804                             .find('span.ui-icon')
805                                 .removeClass('ui-icon-triangle-1-e')
806                                 .addClass('ui-icon-triangle-1-s');
807                         child.open(self.point_insertion(e.currentTarget));
808                     } else {
809                         $row.removeData('open')
810                             .find('span.ui-icon')
811                                 .removeClass('ui-icon-triangle-1-s')
812                                 .addClass('ui-icon-triangle-1-e');
813                         child.close();
814                     }
815                 });
816             }
817             placeholder.appendChild($row[0]);
818
819             var $group_column = $('<th>').appendTo($row);
820             if (group.grouped_on) {
821                 // Don't fill this if group_by_no_leaf but no group_by
822                 $group_column
823                     .text((group.value instanceof Array ? group.value[1] : group.value));
824                 if (group.openable) {
825                     // Make openable if not terminal group & group_by_no_leaf
826                     $group_column
827                         .prepend('<span class="ui-icon ui-icon-triangle-1-e" style="float: left;">');
828                 }
829             }
830             self.indent($group_column, group.level);
831             // count column
832             $('<td>').text(group.length).appendTo($row);
833                     
834             self.pad($row);
835             _(self.columns).chain()
836                 .filter(function (column) {return !column.invisible;})
837                 .each(function (column) {
838                     if (column.meta) {
839                         // do not do anything
840                     } else if (column.id in group.aggregates) {
841                         var value = group.aggregates[column.id];
842                         var format;
843                         if (column.type === 'integer') {
844                             format = "%.0f";
845                         } else if (column.type === 'float') {
846                             format = "%.2f";
847                         }
848                         $('<td>')
849                             .text(_.sprintf(format, value))
850                             .appendTo($row);
851                     } else {
852                         $row.append('<td>');
853                     }
854                 });
855         });
856         return placeholder;
857     },
858     bind_child_events: function (child) {
859         var $this = $(this),
860              self = this;
861         $(child).bind('selected', function (e) {
862             // can have selections spanning multiple links
863             var selection = self.get_selection();
864             $this.trigger(e, [selection.ids, selection.records]);
865         }).bind(this.passtrough_events, function (e) {
866             // additional positional parameters are provided to trigger as an
867             // Array, following the event type or event object, but are
868             // provided to the .bind event handler as *args.
869             // Convert our *args back into an Array in order to trigger them
870             // on the group itself, so it can ultimately be forwarded wherever
871             // it's supposed to go.
872             var args = Array.prototype.slice.call(arguments, 1);
873             $this.trigger.call($this, e, args);
874         });
875     },
876     render_dataset: function (dataset) {
877         var rows = [],
878             list = new openerp.base.ListView.List(this, {
879                 options: this.options,
880                 columns: this.columns,
881                 dataset: dataset,
882                 rows: rows
883             });
884         this.bind_child_events(list);
885
886         var d = new $.Deferred();
887         dataset.read_slice(
888             _.filter(_.pluck(this.columns, 'name'), _.identity),
889             0, false,
890             function (records) {
891                 var form_records = _(records).map(
892                     $.proxy(list, 'transform_record'));
893
894                 rows.splice(0, rows.length);
895                 rows.push.apply(rows, form_records);
896                 list.render();
897                 d.resolve(list);
898             });
899         return d.promise();
900     },
901     render: function (post_render) {
902         var self = this;
903         var $element = $('<tbody>');
904         this.elements = [$element[0]];
905         this.datagroup.list(function (groups) {
906             $element[0].appendChild(
907                 self.render_groups(groups));
908             if (post_render) { post_render(); }
909         }, function (dataset) {
910             self.render_dataset(dataset).then(function (list) {
911                 self.children[null] = list;
912                 self.elements =
913                     [list.$current.replaceAll($element)[0]];
914                 if (post_render) { post_render(); }
915             });
916         });
917         return $element;
918     },
919     /**
920      * Returns the ids of all selected records for this group, and the records
921      * themselves
922      */
923     get_selection: function () {
924         var ids = [], records = [];
925
926         _(this.children)
927             .each(function (child) {
928                 var selection = child.get_selection();
929                 ids.push.apply(ids, selection.ids);
930                 records.push.apply(records, selection.records);
931             });
932
933         return {ids: ids, records: records};
934     },
935     apoptosis: function () {
936         _(this.children).each(function (child) {
937             child.apoptosis();
938         });
939         this.children = {};
940         $(this.elements).remove();
941         return this;
942     },
943     get_records: function () {
944         return _(this.children).chain()
945             .map(function (child) {
946                 return child.get_records();
947             }).flatten().value();
948     }
949 });
950 };
951
952 // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax:
953