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