[IMP] Improved name of function and comment
[odoo/odoo.git] / addons / web / static / src / js / view_list.js
1 openerp.web.list = function (instance) {
2 var _t = instance.web._t,
3    _lt = instance.web._lt;
4 var QWeb = instance.web.qweb;
5 instance.web.views.add('list', 'instance.web.ListView');
6 instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListView# */ {
7     _template: 'ListView',
8     display_name: _lt('List'),
9     defaults: {
10         // records can be selected one by one
11         'selectable': true,
12         // list rows can be deleted
13         'deletable': true,
14         // whether the column headers should be displayed
15         'header': true,
16         // display addition button, with that label
17         'addable': _lt("Create"),
18         // whether the list view can be sorted, note that once a view has been
19         // sorted it can not be reordered anymore
20         'sortable': true,
21         // whether the view rows can be reordered (via vertical drag & drop)
22         'reorderable': true,
23         // display an edit icon linking to form view
24         'isClarkGable': true
25     },
26     /**
27      * Core class for list-type displays.
28      *
29      * As a view, needs a number of view-related parameters to be correctly
30      * instantiated, provides options and overridable methods for behavioral
31      * customization.
32      *
33      * See constructor parameters and method documentations for information on
34      * the default behaviors and possible options for the list view.
35      *
36      * @constructs instance.web.ListView
37      * @extends instance.web.View
38      *
39      * @param parent parent object
40      * @param {instance.web.DataSet} dataset the dataset the view should work with
41      * @param {String} view_id the listview's identifier, if any
42      * @param {Object} options A set of options used to configure the view
43      * @param {Boolean} [options.selectable=true] determines whether view rows are selectable (e.g. via a checkbox)
44      * @param {Boolean} [options.header=true] should the list's header be displayed
45      * @param {Boolean} [options.deletable=true] are the list rows deletable
46      * @param {void|String} [options.addable="New"] should the new-record button be displayed, and what should its label be. Use ``null`` to hide the button.
47      * @param {Boolean} [options.sortable=true] is it possible to sort the table by clicking on column headers
48      * @param {Boolean} [options.reorderable=true] is it possible to reorder list rows
49      */
50     init: function(parent, dataset, view_id, options) {
51         var self = this;
52         this._super(parent);
53         this.set_default_options(_.extend({}, this.defaults, options || {}));
54         this.dataset = dataset;
55         this.model = dataset.model;
56         this.view_id = view_id;
57         this.previous_colspan = null;
58         this.colors = null;
59         this.fonts = null;
60
61         this.columns = [];
62
63         this.records = new Collection();
64
65         this.set_groups(new instance.web.ListView.Groups(this));
66
67         if (this.dataset instanceof instance.web.DataSetStatic) {
68             this.groups.datagroup = new instance.web.StaticDataGroup(this.dataset);
69         } else {
70             this.groups.datagroup = new instance.web.DataGroup(
71                 this, this.model,
72                 dataset.get_domain(),
73                 dataset.get_context(),
74                 {});
75             this.groups.datagroup.sort = this.dataset._sort;
76         }
77
78         this.page = 0;
79         this.records.bind('change', function (event, record, key) {
80             if (!_(self.aggregate_columns).chain()
81                     .pluck('name').contains(key).value()) {
82                 return;
83             }
84             self.compute_aggregates();
85         });
86
87         this.no_leaf = false;
88     },
89     /**
90      * Retrieves the view's number of records per page (|| section)
91      *
92      * options > defaults > parent.action.limit > indefinite
93      *
94      * @returns {Number|null}
95      */
96     limit: function () {
97         if (this._limit === undefined) {
98             this._limit = (this.options.limit
99                         || this.defaults.limit
100                         || (this.getParent().action || {}).limit
101                         || 80);
102         }
103         return this._limit;
104     },
105     /**
106      * Set a custom Group construct as the root of the List View.
107      *
108      * @param {instance.web.ListView.Groups} groups
109      */
110     set_groups: function (groups) {
111         var self = this;
112         if (this.groups) {
113             $(this.groups).unbind("selected deleted action row_link");
114             delete this.groups;
115         }
116
117         this.groups = groups;
118         $(this.groups).bind({
119             'selected': function (e, ids, records) {
120                 self.do_select(ids, records);
121             },
122             'deleted': function (e, ids) {
123                 self.do_delete(ids);
124             },
125             'action': function (e, action_name, id, callback) {
126                 self.do_button_action(action_name, id, callback);
127             },
128             'row_link': function (e, id, dataset, view) {
129                 self.do_activate_record(dataset.index, id, dataset, view);
130             }
131         });
132     },
133     /**
134      * View startup method, the default behavior is to set the ``oe-listview``
135      * class on its root element and to perform an RPC load call.
136      *
137      * @returns {$.Deferred} loading promise
138      */
139     start: function() {
140         this.$element.addClass('oe-listview');
141         return this.reload_view(null, null, true);
142     },
143     /**
144      * Returns the style for the provided record in the current view (from the
145      * ``@colors`` attribute and ``@fonts`` attribute)
146      *
147      * @param {Record} record record for the current row
148      * @returns {String} CSS style declaration
149      */
150     style_for: function (record) {
151         var style= '';
152
153         var context = _.extend({}, record.attributes, {
154             uid: this.session.uid,
155             current_date: new Date().toString('yyyy-MM-dd')
156             // TODO: time, datetime, relativedelta
157         });
158
159         if (this.fonts) {
160                 for(var i=0, len=this.fonts.length; i<len; ++i) {
161                     var pair = this.fonts[i],
162                     font = pair[0],
163                     expression = pair[1];
164                 if (py.evaluate(expression, context).toJSON()) {
165                     switch(font) {
166                     case 'bold':
167                         style += 'font-weight: bold;';
168                         break;
169                     case 'italic':
170                         style += 'font-style: italic;';
171                         break;
172                     case 'underline':
173                         style += 'text-decoration: underline;';
174                         break;
175                     }
176                 }
177             }
178         }
179
180         if (!this.colors) { return style; }
181         for(var i=0, len=this.colors.length; i<len; ++i) {
182             var pair = this.colors[i],
183                 color = pair[0],
184                 expression = pair[1];
185             if (py.evaluate(expression, context).toJSON()) {
186                 return style += 'color: ' + color + ';';
187             }
188             // TODO: handle evaluation errors
189         }
190         return style;
191     },
192     /**
193      * Called after loading the list view's description, sets up such things
194      * as the view table's columns, renders the table itself and hooks up the
195      * various table-level and row-level DOM events (action buttons, deletion
196      * buttons, selection of records, [New] button, selection of a given
197      * record, ...)
198      *
199      * Sets up the following:
200      *
201      * * Processes arch and fields to generate a complete field descriptor for each field
202      * * Create the table itself and allocate visible columns
203      * * Hook in the top-level (header) [New|Add] and [Delete] button
204      * * Sets up showing/hiding the top-level [Delete] button based on records being selected or not
205      * * Sets up event handlers for action buttons and per-row deletion button
206      * * Hooks global callback for clicking on a row
207      * * Sets up its sidebar, if any
208      *
209      * @param {Object} data wrapped fields_view_get result
210      * @param {Object} data.fields_view fields_view_get result (processed)
211      * @param {Object} data.fields_view.fields mapping of fields for the current model
212      * @param {Object} data.fields_view.arch current list view descriptor
213      * @param {Boolean} grouped Is the list view grouped
214      */
215     on_loaded: function(data, grouped) {
216         var self = this;
217         this.fields_view = data;
218         this.name = "" + this.fields_view.arch.attrs.string;
219
220         if (this.fields_view.arch.attrs.colors) {
221             this.colors = _(this.fields_view.arch.attrs.colors.split(';')).chain()
222                 .compact()
223                 .map(function(color_pair) {
224                     var pair = color_pair.split(':'),
225                         color = pair[0],
226                         expr = pair[1];
227                     return [color, py.parse(py.tokenize(expr)), expr];
228                 }).value();
229         }
230
231         if (this.fields_view.arch.attrs.fonts) {
232             this.fonts = _(this.fields_view.arch.attrs.fonts.split(';')).chain().compact()
233                 .map(function(font_pair) {
234                     var pair = font_pair.split(':'),
235                         font = pair[0],
236                         expr = pair[1];
237                     return [font, py.parse(py.tokenize(expr)), expr];
238                 }).value();
239         }
240
241         this.setup_columns(this.fields_view.fields, grouped);
242
243         this.$element.html(QWeb.render(this._template, this));
244         // Head hook
245         // Selecting records
246         this.$element.find('.all-record-selector').click(function(){
247             self.$element.find('.oe-record-selector input').prop('checked',
248                 self.$element.find('.all-record-selector').prop('checked')  || false);
249             var selection = self.groups.get_selection();
250             $(self.groups).trigger(
251                 'selected', [selection.ids, selection.records]);
252         });
253
254         // Sorting columns
255         this.$element.find('thead').delegate('th.oe-sortable[data-id]', 'click', function (e) {
256             e.stopPropagation();
257             var $this = $(this);
258             self.dataset.sort($this.data('id'));
259             if ($this.find('span').length) {
260                 $this.find('span').toggleClass( 'ui-icon-triangle-1-s ui-icon-triangle-1-n');
261             } else {
262                 $this.append('<span class="ui-icon ui-icon-triangle-1-n">') .siblings('.oe-sortable').find('span').remove();
263             }
264
265             self.reload_content();
266         });
267
268         // Add and delete
269         if (!this.$buttons) {
270             this.$buttons = $(QWeb.render("ListView.buttons", {'widget':self}));
271             if (this.options.$buttons) {
272                 this.$buttons.appendTo(this.options.$buttons);
273             } else {
274                 this.$element.find('.oe_list_buttons').replaceWith(this.$buttons);
275             }
276             this.$buttons.find('.oe_list_add')
277                     .click(this.proxy('do_add_record'))
278                     .prop('disabled', grouped && this.options.editable)
279                 .end()
280                 .find('.oe_list_delete')
281                     .click(this.proxy('do_delete_selected'))
282                     .prop('disabled', true);
283         }
284
285         // Pager
286         if (!this.$pager) {
287             this.$pager = $(QWeb.render("ListView.pager", {'widget':self}));
288             if (this.options.$buttons) {
289                 this.$pager.appendTo(this.options.$pager);
290             } else {
291                 this.$element.find('.oe_list_pager').replaceWith(this.$pager);
292             }
293
294             this.$pager
295                 .on('click', 'a[data-pager-action]', function () {
296                     var $this = $(this);
297                     var max_page = Math.floor(self.dataset.size() / self.limit());
298                     switch ($this.data('pager-action')) {
299                         case 'first':
300                             self.page = 0; break;
301                         case 'last':
302                             self.page = max_page - 1;
303                             break;
304                         case 'next':
305                             self.page += 1; break;
306                         case 'previous':
307                             self.page -= 1; break;
308                     }
309                     if (self.page < 0) {
310                         self.page = max_page;
311                     } else if (self.page > max_page) {
312                         self.page = 0;
313                     }
314                     self.reload_content();
315                 }).find('.oe-pager-state')
316                     .click(function (e) {
317                         e.stopPropagation();
318                         var $this = $(this);
319
320                         var $select = $('<select>')
321                             .appendTo($this.empty())
322                             .click(function (e) {e.stopPropagation();})
323                             .append('<option value="80">80</option>' +
324                                     '<option value="100">100</option>' +
325                                     '<option value="200">200</option>' +
326                                     '<option value="500">500</option>' +
327                                     '<option value="NaN">' + _t("Unlimited") + '</option>')
328                             .change(function () {
329                                 var val = parseInt($select.val(), 10);
330                                 self._limit = (isNaN(val) ? null : val);
331                                 self.page = 0;
332                                 self.reload_content();
333                             })
334                             .val(self._limit || 'NaN');
335                     });
336         }
337
338         // Sidebar
339         if (!this.sidebar && this.options.sidebar && this.options.$sidebar) {
340             this.sidebar = new instance.web.Sidebar(this);
341             this.sidebar.appendTo(this.options.$sidebar);
342             this.sidebar.add_toolbar(this.fields_view.toolbar);
343         }
344     },
345     /**
346      * Configures the ListView pager based on the provided dataset's information
347      *
348      * Horrifying side-effect: sets the dataset's data on this.dataset?
349      *
350      * @param {instance.web.DataSet} dataset
351      */
352     configure_pager: function (dataset) {
353         this.dataset.ids = dataset.ids;
354         // Not exactly clean
355         if (dataset._length) {
356             this.dataset._length = dataset._length;
357         }
358
359         var page = this.page + 1,
360            total = Math.floor(dataset.size() / this.limit()) + 1;
361
362         this.$pager.find('.oe-pager-state').text(isNaN(total)
363                 ? '-' : _.str.sprintf('%d / %d', page, total));
364     },
365     /**
366      * Sets up the listview's columns: merges view and fields data, move
367      * grouped-by columns to the front of the columns list and make them all
368      * visible.
369      *
370      * @param {Object} fields fields_view_get's fields section
371      * @param {Boolean} [grouped] Should the grouping columns (group and count) be displayed
372      */
373     setup_columns: function (fields, grouped) {
374         var domain_computer = instance.web.form.compute_domain;
375
376         var noop = function () { return {}; };
377         var field_to_column = function (field) {
378             var name = field.attrs.name;
379             var column = _.extend({id: name, tag: field.tag},
380                     fields[name], field.attrs);
381             // modifiers computer
382             if (column.modifiers) {
383                 var modifiers = JSON.parse(column.modifiers);
384                 column.modifiers_for = function (fields) {
385                     if (!modifiers.invisible) {
386                         return {};
387                     }
388                     return {
389                         'invisible': domain_computer(modifiers.invisible, fields)
390                     };
391                 };
392                 if (modifiers['tree_invisible']) {
393                     column.invisible = '1';
394                 } else {
395                     delete column.invisible;
396                 }
397             } else {
398                 column.modifiers_for = noop;
399             }
400             return column;
401         };
402
403         this.columns.splice(0, this.columns.length);
404         this.columns.push.apply(
405                 this.columns,
406                 _(this.fields_view.arch.children).map(field_to_column));
407         if (grouped) {
408             this.columns.unshift({
409                 id: '_group', tag: '', string: _t("Group"), meta: true,
410                 modifiers_for: function () { return {}; }
411             }, {
412                 id: '_count', tag: '', string: '#', meta: true,
413                 modifiers_for: function () { return {}; }
414             });
415         }
416
417         this.visible_columns = _.filter(this.columns, function (column) {
418             return column.invisible !== '1';
419         });
420
421         this.aggregate_columns = _(this.visible_columns)
422             .map(function (column) {
423                 if (column.type !== 'integer' && column.type !== 'float') {
424                     return {};
425                 }
426                 var aggregation_func = column['group_operator'] || 'sum';
427                 if (!(aggregation_func in column)) {
428                     return {};
429                 }
430
431                 return _.extend({}, column, {
432                     'function': aggregation_func,
433                     label: column[aggregation_func]
434                 });
435             });
436     },
437     /**
438      * Used to handle a click on a table row, if no other handler caught the
439      * event.
440      *
441      * The default implementation asks the list view's view manager to switch
442      * to a different view (by calling
443      * :js:func:`~instance.web.ViewManager.on_mode_switch`), using the
444      * provided record index (within the current list view's dataset).
445      *
446      * If the index is null, ``switch_to_record`` asks for the creation of a
447      * new record.
448      *
449      * @param {Number|void} index the record index (in the current dataset) to switch to
450      * @param {String} [view="page"] the view type to switch to
451      */
452     select_record:function (index, view) {
453         view = view || index == null ? 'form' : 'form';
454         this.dataset.index = index;
455         _.delay(_.bind(function () {
456             this.do_switch_view(view);
457         }, this));
458     },
459     do_show: function () {
460         this._super();
461         if (this.sidebar) {
462             this.sidebar.$element.show();
463         }
464         if (this.$buttons) {
465             this.$buttons.show();
466         }
467         if (this.$pager) {
468             this.$pager.show();
469         }
470     },
471     do_hide: function () {
472         if (this.sidebar) {
473             this.sidebar.$element.hide();
474         }
475         if (this.$buttons) {
476             this.$buttons.hide();
477         }
478         if (this.$pager) {
479             this.$pager.hide();
480         }
481         this._super();
482     },
483     /**
484      * Reloads the list view based on the current settings (dataset & al)
485      *
486      * @param {Boolean} [grouped] Should the list be displayed grouped
487      * @param {Object} [context] context to send the server while loading the view
488      */
489     reload_view: function (grouped, context, initial) {
490         var self = this;
491         var callback = function (field_view_get) {
492             self.on_loaded(field_view_get, grouped);
493         };
494         if (this.embedded_view) {
495             return $.Deferred().then(callback).resolve(this.embedded_view);
496         } else {
497             return this.rpc('/web/listview/load', {
498                 model: this.model,
499                 view_id: this.view_id,
500                 view_type: "tree",
501                 context: this.dataset.get_context(context),
502                 toolbar: this.options.sidebar
503             }, callback);
504         }
505     },
506     /**
507      * re-renders the content of the list view
508      *
509      * @returns {$.Deferred} promise to content reloading
510      */
511     reload_content: function () {
512         var self = this;
513         self.$element.find('.all-record-selector').prop('checked', false);
514         this.records.reset();
515         var reloaded = $.Deferred();
516         this.$element.find('.oe-listview-content').append(
517             this.groups.render(function () {
518                 if (self.dataset.index == null) {
519                     var has_one = false;
520                     self.records.each(function () { has_one = true; });
521                     if (has_one) {
522                         self.dataset.index = 0;
523                     }
524                 }
525                 self.compute_aggregates();
526                 reloaded.resolve();
527             }));
528         this.do_push_state({
529             page: this.page,
530             limit: this._limit
531         });
532         return reloaded.promise();
533     },
534     reload: function () {
535         return this.reload_content();
536     },
537     do_load_state: function(state, warm) {
538         var reload = false;
539         if (state.page && this.page !== state.page) {
540             this.page = state.page;
541             reload = true;
542         }
543         if (state.limit) {
544             if (_.isString(state.limit)) {
545                 state.limit = null;
546             }
547             if (state.limit !== this._limit) {
548                 this._limit = state.limit;
549                 reload = true;
550             }
551         }
552         if (reload) {
553             this.reload_content();
554         }
555     },
556     /**
557      * Handler for the result of eval_domain_and_context, actually perform the
558      * searching
559      *
560      * @param {Object} results results of evaluating domain and process for a search
561      */
562     do_search: function (domain, context, group_by) {
563         this.page = 0;
564         this.groups.datagroup = new instance.web.DataGroup(
565             this, this.model, domain, context, group_by);
566         this.groups.datagroup.sort = this.dataset._sort;
567
568         if (_.isEmpty(group_by) && !context['group_by_no_leaf']) {
569             group_by = null;
570         }
571         this.no_leaf = !!context['group_by_no_leaf'];
572
573         this.reload_view(!!group_by, context).then(
574             this.proxy('reload_content'));
575     },
576     /**
577      * Handles the signal to delete lines from the records list
578      *
579      * @param {Array} ids the ids of the records to delete
580      */
581     do_delete: function (ids) {
582         if (!(ids.length && confirm(_t("Do you really want to remove these records?")))) {
583             return;
584         }
585         var self = this;
586         return $.when(this.dataset.unlink(ids)).then(function () {
587             _(ids).each(function (id) {
588                 self.records.remove(self.records.get(id));
589             });
590             self.configure_pager(self.dataset);
591             self.compute_aggregates();
592         });
593     },
594     /**
595      * Handles the signal indicating that a new record has been selected
596      *
597      * @param {Array} ids selected record ids
598      * @param {Array} records selected record values
599      */
600     do_select: function (ids, records) {
601         this.$buttons.find('.oe_list_delete').attr('disabled', !ids.length);
602         if (!ids.length) {
603             this.dataset.index = 0;
604             if (this.sidebar) {
605                 this.sidebar.$element.hide();
606             }
607             this.compute_aggregates();
608             return;
609         }
610
611         this.dataset.index = _(this.dataset.ids).indexOf(ids[0]);
612         if (this.sidebar) {
613             this.sidebar.$element.show();
614         }
615
616         this.compute_aggregates(_(records).map(function (record) {
617             return {count: 1, values: record};
618         }));
619     },
620     /**
621      * Handles action button signals on a record
622      *
623      * @param {String} name action name
624      * @param {Object} id id of the record the action should be called on
625      * @param {Function} callback should be called after the action is executed, if non-null
626      */
627     do_button_action: function (name, id, callback) {
628         var action = _.detect(this.columns, function (field) {
629             return field.name === name;
630         });
631         if (!action) { return; }
632         if ('confirm' in action && !window.confirm(action.confirm)) {
633             return;
634         }
635
636         var c = new instance.web.CompoundContext();
637         c.set_eval_context(_.extend({
638             active_id: id,
639             active_ids: [id],
640             active_model: this.dataset.model
641         }, this.records.get(id).toContext()));
642         if (action.context) {
643             c.add(action.context);
644         }
645         action.context = c;
646         this.do_execute_action(action, this.dataset, id, callback);
647     },
648     /**
649      * Handles the activation of a record (clicking on it)
650      *
651      * @param {Number} index index of the record in the dataset
652      * @param {Object} id identifier of the activated record
653      * @param {instance.web.DataSet} dataset dataset in which the record is available (may not be the listview's dataset in case of nested groups)
654      */
655     do_activate_record: function (index, id, dataset, view) {
656         this.dataset.ids = dataset.ids;
657         this.select_record(index, view);
658     },
659     /**
660      * Handles signal for the addition of a new record (can be a creation,
661      * can be the addition from a remote source, ...)
662      *
663      * The default implementation is to switch to a new record on the form view
664      */
665     do_add_record: function () {
666         this.select_record(null);
667     },
668     /**
669      * Handles deletion of all selected lines
670      */
671     do_delete_selected: function () {
672         this.do_delete(this.groups.get_selection().ids);
673     },
674     /**
675      * Computes the aggregates for the current list view, either on the
676      * records provided or on the records of the internal
677      * :js:class:`~instance.web.ListView.Group`, by calling
678      * :js:func:`~instance.web.ListView.group.get_records`.
679      *
680      * Then displays the aggregates in the table through
681      * :js:method:`~instance.web.ListView.display_aggregates`.
682      *
683      * @param {Array} [records]
684      */
685     compute_aggregates: function (records) {
686         var columns = _(this.aggregate_columns).filter(function (column) {
687             return column['function']; });
688         if (_.isEmpty(columns)) { return; }
689
690         if (_.isEmpty(records)) {
691             records = this.groups.get_records();
692         }
693         records = _(records).compact();
694
695         var count = 0, sums = {};
696         _(columns).each(function (column) {
697             switch (column['function']) {
698                 case 'max':
699                     sums[column.id] = -Infinity;
700                     break;
701                 case 'min':
702                     sums[column.id] = Infinity;
703                     break;
704                 default:
705                     sums[column.id] = 0;
706             }
707         });
708         _(records).each(function (record) {
709             count += record.count || 1;
710             _(columns).each(function (column) {
711                 var field = column.id,
712                     value = record.values[field];
713                 switch (column['function']) {
714                     case 'sum':
715                         sums[field] += value;
716                         break;
717                     case 'avg':
718                         sums[field] += record.count * value;
719                         break;
720                     case 'min':
721                         if (sums[field] > value) {
722                             sums[field] = value;
723                         }
724                         break;
725                     case 'max':
726                         if (sums[field] < value) {
727                             sums[field] = value;
728                         }
729                         break;
730                 }
731             });
732         });
733
734         var aggregates = {};
735         _(columns).each(function (column) {
736             var field = column.id;
737             switch (column['function']) {
738                 case 'avg':
739                     aggregates[field] = {value: sums[field] / count};
740                     break;
741                 default:
742                     aggregates[field] = {value: sums[field]};
743             }
744         });
745
746         this.display_aggregates(aggregates);
747     },
748     display_aggregates: function (aggregation) {
749         var self = this;
750         var $footer_cells = this.$element.find('.oe-list-footer');
751         _(this.aggregate_columns).each(function (column) {
752             if (!column['function']) {
753                 return;
754             }
755
756             $footer_cells.filter(_.str.sprintf('[data-field=%s]', column.id))
757                 .html(instance.web.format_cell(aggregation, column, {
758                     process_modifiers: false
759             }));
760         });
761     },
762     get_selected_ids: function() {
763         var ids = this.groups.get_selection().ids;
764         return ids;
765     },
766     /**
767      * Adds padding columns at the start or end of all table rows (including
768      * field names row)
769      *
770      * @param {Number} count number of columns to add
771      * @param {Object} options
772      * @param {"before"|"after"} [options.position="after"] insertion position for the new columns
773      * @param {Object} [options.except] content row to not pad
774      */
775     pad_columns: function (count, options) {
776         options = options || {};
777         // padding for action/pager header
778         var $first_header = this.$element.find('thead tr:first th');
779         var colspan = $first_header.attr('colspan');
780         if (colspan) {
781             if (!this.previous_colspan) {
782                 this.previous_colspan = colspan;
783             }
784             $first_header.attr('colspan', parseInt(colspan, 10) + count);
785         }
786         // Padding for column titles, footer and data rows
787         var $rows = this.$element
788                 .find('.oe-listview-header-columns, tr:not(thead tr)')
789                 .not(options['except']);
790         var newcols = new Array(count+1).join('<td class="oe-listview-padding"></td>');
791         if (options.position === 'before') {
792             $rows.prepend(newcols);
793         } else {
794             $rows.append(newcols);
795         }
796     },
797     /**
798      * Removes all padding columns of the table
799      */
800     unpad_columns: function () {
801         this.$element.find('.oe-listview-padding').remove();
802         if (this.previous_colspan) {
803             this.$element
804                     .find('thead tr:first th')
805                     .attr('colspan', this.previous_colspan);
806             this.previous_colspan = null;
807         }
808     }
809 });
810 instance.web.ListView.List = instance.web.Class.extend( /** @lends instance.web.ListView.List# */{
811     /**
812      * List display for the ListView, handles basic DOM events and transforms
813      * them in the relevant higher-level events, to which the list view (or
814      * other consumers) can subscribe.
815      *
816      * Events on this object are registered via jQuery.
817      *
818      * Available events:
819      *
820      * `selected`
821      *   Triggered when a row is selected (using check boxes), provides an
822      *   array of ids of all the selected records.
823      * `deleted`
824      *   Triggered when deletion buttons are hit, provide an array of ids of
825      *   all the records being marked for suppression.
826      * `action`
827      *   Triggered when an action button is clicked, provides two parameters:
828      *
829      *   * The name of the action to execute (as a string)
830      *   * The id of the record to execute the action on
831      * `row_link`
832      *   Triggered when a row of the table is clicked, provides the index (in
833      *   the rows array) and id of the selected record to the handle function.
834      *
835      * @constructs instance.web.ListView.List
836      * @extends instance.web.Class
837      * 
838      * @param {Object} opts display options, identical to those of :js:class:`instance.web.ListView`
839      */
840     init: function (group, opts) {
841         var self = this;
842         this.group = group;
843         this.view = group.view;
844         this.session = this.view.session;
845
846         this.options = opts.options;
847         this.columns = opts.columns;
848         this.dataset = opts.dataset;
849         this.records = opts.records;
850
851         this.record_callbacks = {
852             'remove': function (event, record) {
853                 var $row = self.$current.find(
854                         '[data-id=' + record.get('id') + ']');
855                 var index = $row.data('index');
856                 $row.remove();
857                 self.refresh_zebra(index);
858             },
859             'reset': function () { return self.on_records_reset(); },
860             'change': function (event, record) {
861                 var $row = self.$current.find('[data-id=' + record.get('id') + ']');
862                 $row.replaceWith(self.render_record(record));
863             },
864             'add': function (ev, records, record, index) {
865                 var $new_row = $('<tr>').attr({
866                     'data-id': record.get('id')
867                 });
868
869                 if (index === 0) {
870                     $new_row.prependTo(self.$current);
871                 } else {
872                     var previous_record = records.at(index-1),
873                         $previous_sibling = self.$current.find(
874                                 '[data-id=' + previous_record.get('id') + ']');
875                     $new_row.insertAfter($previous_sibling);
876                 }
877
878                 self.refresh_zebra(index, 1);
879             }
880         };
881         _(this.record_callbacks).each(function (callback, event) {
882             this.records.bind(event, callback);
883         }, this);
884
885         this.$_element = $('<tbody>')
886             .appendTo(document.body)
887             .delegate('th.oe-record-selector', 'click', function (e) {
888                 e.stopPropagation();
889                 var selection = self.get_selection();
890                 $(self).trigger(
891                         'selected', [selection.ids, selection.records]);
892             })
893             .delegate('td.oe-record-delete button', 'click', function (e) {
894                 e.stopPropagation();
895                 var $row = $(e.target).closest('tr');
896                 $(self).trigger('deleted', [[self.row_id($row)]]);
897             })
898             .delegate('td.oe-field-cell button', 'click', function (e) {
899                 e.stopPropagation();
900                 var $target = $(e.currentTarget),
901                       field = $target.closest('td').data('field'),
902                        $row = $target.closest('tr'),
903                   record_id = self.row_id($row);
904
905                 // note: $.data converts data to number if it's composed only
906                 // of digits, nice when storing actual numbers, not nice when
907                 // storing strings composed only of digits. Force the action
908                 // name to be a string
909                 $(self).trigger('action', [field.toString(), record_id, function () {
910                     return self.reload_record(self.records.get(record_id));
911                 }]);
912             })
913             .delegate('a', 'click', function (e) {
914                 e.stopPropagation();
915             })
916             .delegate('tr', 'click', function (e) {
917                 e.stopPropagation();
918                 var row_id = self.row_id(e.currentTarget);
919                 if (row_id !== undefined) {
920                     if (!self.dataset.select_id(row_id)) {
921                         throw "Could not find id in dataset"
922                     }
923                     var view;
924                     if ($(e.target).is('.oe-record-edit-link-img')) {
925                         view = 'form';
926                     }
927                     self.row_clicked(e, view);
928                 }
929             });
930     },
931     row_clicked: function (e, view) {
932         $(this).trigger(
933             'row_link',
934             [this.dataset.ids[this.dataset.index],
935              this.dataset, view]);
936     },
937     render_cell: function (record, column) {
938         var value;
939         if(column.type === 'reference') {
940             value = record.get(column.id);
941             var ref_match;
942             // Ensure that value is in a reference "shape", otherwise we're
943             // going to loop on performing name_get after we've resolved (and
944             // set) a human-readable version. m2o does not have this issue
945             // because the non-human-readable is just a number, where the
946             // human-readable version is a pair
947             if (value && (ref_match = /([\w\.]+),(\d+)/.exec(value))) {
948                 // reference values are in the shape "$model,$id" (as a
949                 // string), we need to split and name_get this pair in order
950                 // to get a correctly displayable value in the field
951                 var model = ref_match[1],
952                     id = parseInt(ref_match[2], 10);
953                 new instance.web.DataSet(this.view, model).name_get([id], function(names) {
954                     if (!names.length) { return; }
955                     record.set(column.id, names[0][1]);
956                 });
957             }
958         } else if (column.type === 'many2one') {
959             value = record.get(column.id);
960             // m2o values are usually name_get formatted, [Number, String]
961             // pairs, but in some cases only the id is provided. In these
962             // cases, we need to perform a name_get call to fetch the actual
963             // displayable value
964             if (typeof value === 'number' || value instanceof Number) {
965                 // fetch the name, set it on the record (in the right field)
966                 // and let the various registered events handle refreshing the
967                 // row
968                 new instance.web.DataSet(this.view, column.relation)
969                         .name_get([value], function (names) {
970                     if (!names.length) { return; }
971                     record.set(column.id, names[0]);
972                 });
973             }
974         }
975         return instance.web.format_cell(record.toForm().data, column, {
976             model: this.dataset.model,
977             id: record.get('id')
978         });
979     },
980     render: function () {
981         var self = this;
982         if (this.$current) {
983             this.$current.remove();
984         }
985         this.$current = this.$_element.clone(true);
986         this.$current.empty().append(
987             QWeb.render('ListView.rows', _.extend({
988                     render_cell: function () {
989                         return self.render_cell.apply(self, arguments); }
990                 }, this)));
991         this.pad_table_to(5);
992     },
993     pad_table_to: function (count) {
994         if (this.records.length >= count ||
995                 _(this.columns).any(function(column) { return column.meta; })) {
996             return;
997         }
998         var cells = [];
999         if (this.options.selectable) {
1000             cells.push('<th class="oe-record-selector"></td>');
1001         }
1002         if (this.options.isClarkGable) {
1003             cells.push('<th class="oe-record-edit-link"></td>');
1004         }
1005         _(this.columns).each(function(column) {
1006             if (column.invisible === '1') {
1007                 return;
1008             }
1009             if (column.tag === 'button') {
1010                 cells.push('<td class="oe-button" title="' + column.string + '">&nbsp;</td>');
1011             } else {
1012                 cells.push('<td title="' + column.string + '">&nbsp;</td>');
1013             }
1014         });
1015         if (this.options.deletable) {
1016             cells.push('<td class="oe-record-delete"><button type="button" style="visibility: hidden"> </button></td>');
1017         }
1018         cells.unshift('<tr>');
1019         cells.push('</tr>');
1020
1021         var row = cells.join('');
1022         this.$current
1023             .children('tr:not([data-id])').remove().end()
1024             .append(new Array(count - this.records.length + 1).join(row));
1025         this.refresh_zebra(this.records.length);
1026     },
1027     /**
1028      * Gets the ids of all currently selected records, if any
1029      * @returns {Object} object with the keys ``ids`` and ``records``, holding respectively the ids of all selected records and the records themselves.
1030      */
1031     get_selection: function () {
1032         if (!this.options.selectable) {
1033             return [];
1034         }
1035         var records = this.records;
1036         var result = {ids: [], records: []};
1037         this.$current.find('th.oe-record-selector input:checked')
1038                 .closest('tr').each(function () {
1039             var record = records.get($(this).data('id'));
1040             result.ids.push(record.get('id'));
1041             result.records.push(record.attributes);
1042         });
1043         return result;
1044     },
1045     /**
1046      * Returns the identifier of the object displayed in the provided table
1047      * row
1048      *
1049      * @param {Object} row the selected table row
1050      * @returns {Number|String} the identifier of the row's object
1051      */
1052     row_id: function (row) {
1053         return $(row).data('id');
1054     },
1055     /**
1056      * Death signal, cleans up list display
1057      */
1058     on_records_reset: function () {
1059         _(this.record_callbacks).each(function (callback, event) {
1060             this.records.unbind(event, callback);
1061         }, this);
1062         if (!this.$current) { return; }
1063         this.$current.remove();
1064         this.$current = null;
1065         this.$_element.remove();
1066     },
1067     get_records: function () {
1068         return this.records.map(function (record) {
1069             return {count: 1, values: record.attributes};
1070         });
1071     },
1072     /**
1073      * Reloads the provided record by re-reading its content from the server.
1074      *
1075      * @param {Record} record
1076      * @returns {$.Deferred} promise to the finalization of the reloading
1077      */
1078     reload_record: function (record) {
1079         return this.dataset.read_ids(
1080             [record.get('id')],
1081             _.pluck(_(this.columns).filter(function (r) {
1082                     return r.tag === 'field';
1083                 }), 'name')
1084         ).then(function (records) {
1085             _(records[0]).each(function (value, key) {
1086                 record.set(key, value, {silent: true});
1087             });
1088             record.trigger('change', record);
1089         });
1090     },
1091     /**
1092      * Renders a list record to HTML
1093      *
1094      * @param {Record} record index of the record to render in ``this.rows``
1095      * @returns {String} QWeb rendering of the selected record
1096      */
1097     render_record: function (record) {
1098         var index = this.records.indexOf(record);
1099         return QWeb.render('ListView.row', {
1100             columns: this.columns,
1101             options: this.options,
1102             record: record,
1103             row_parity: (index % 2 === 0) ? 'even' : 'odd',
1104             view: this.view,
1105             render_cell: function () {
1106                 return this.render_cell.apply(this, arguments); }
1107         });
1108     },
1109     /**
1110      * Fixes fixes the even/odd classes
1111      *
1112      * @param {Number} [from_index] index from which to resequence
1113      * @param {Number} [offset = 0] selection offset for DOM, in case there are rows to ignore in the table
1114      */
1115     refresh_zebra: function (from_index, offset) {
1116         offset = offset || 0;
1117         from_index = from_index || 0;
1118         var dom_offset = offset + from_index;
1119         var sel = dom_offset ? ':gt(' + (dom_offset - 1) + ')' : null;
1120         this.$current.children(sel).each(function (i, e) {
1121             var index = from_index + i;
1122             // reset record-index accelerators on rows and even/odd
1123             var even = index%2 === 0;
1124             $(e).toggleClass('even', even)
1125                 .toggleClass('odd', !even);
1126         });
1127     }
1128 });
1129 instance.web.ListView.Groups = instance.web.Class.extend( /** @lends instance.web.ListView.Groups# */{
1130     passtrough_events: 'action deleted row_link',
1131     /**
1132      * Grouped display for the ListView. Handles basic DOM events and interacts
1133      * with the :js:class:`~instance.web.DataGroup` bound to it.
1134      *
1135      * Provides events similar to those of
1136      * :js:class:`~instance.web.ListView.List`
1137      *
1138      * @constructs instance.web.ListView.Groups
1139      * @extends instance.web.Class
1140      *
1141      * @param {instance.web.ListView} view
1142      * @param {Object} [options]
1143      * @param {Collection} [options.records]
1144      * @param {Object} [options.options]
1145      * @param {Array} [options.columns]
1146      */
1147     init: function (view, options) {
1148         options = options || {};
1149         this.view = view;
1150         this.records = options.records || view.records;
1151         this.options = options.options || view.options;
1152         this.columns = options.columns || view.columns;
1153         this.datagroup = null;
1154
1155         this.$row = null;
1156         this.children = {};
1157
1158         this.page = 0;
1159
1160         var self = this;
1161         this.records.bind('reset', function () {
1162             return self.on_records_reset(); });
1163     },
1164     make_fragment: function () {
1165         return document.createDocumentFragment();
1166     },
1167     /**
1168      * Returns a DOM node after which a new tbody can be inserted, so that it
1169      * follows the provided row.
1170      *
1171      * Necessary to insert the result of a new group or list view within an
1172      * existing groups render, without losing track of the groups's own
1173      * elements
1174      *
1175      * @param {HTMLTableRowElement} row the row after which the caller wants to insert a body
1176      * @returns {HTMLTableSectionElement} element after which a tbody can be inserted
1177      */
1178     point_insertion: function (row) {
1179         var $row = $(row);
1180         var red_letter_tboday = $row.closest('tbody')[0];
1181
1182         var $next_siblings = $row.nextAll();
1183         if ($next_siblings.length) {
1184             var $root_kanal = $('<tbody>').insertAfter(red_letter_tboday);
1185
1186             $root_kanal.append($next_siblings);
1187             this.elements.splice(
1188                 _.indexOf(this.elements, red_letter_tboday),
1189                 0,
1190                 $root_kanal[0]);
1191         }
1192         return red_letter_tboday;
1193     },
1194     make_paginator: function () {
1195         var self = this;
1196         var $prev = $('<button type="button" data-pager-action="previous">&lt;</button>')
1197             .click(function (e) {
1198                 e.stopPropagation();
1199                 self.page -= 1;
1200
1201                 self.$row.closest('tbody').next()
1202                     .replaceWith(self.render());
1203             });
1204         var $next = $('<button type="button" data-pager-action="next">&gt;</button>')
1205             .click(function (e) {
1206                 e.stopPropagation();
1207                 self.page += 1;
1208
1209                 self.$row.closest('tbody').next()
1210                     .replaceWith(self.render());
1211             });
1212         this.$row.children().last()
1213             .append($prev)
1214             .append('<span class="oe-pager-state"></span>')
1215             .append($next);
1216     },
1217     open: function (point_insertion) {
1218         this.render().insertAfter(point_insertion);
1219
1220         var no_subgroups = _(this.datagroup.group_by).isEmpty(),
1221             records_terminated = !this.datagroup.context['group_by_no_leaf'];
1222         if (no_subgroups && records_terminated) {
1223             this.make_paginator();
1224         }
1225     },
1226     close: function () {
1227         this.$row.children().last().empty();
1228         this.records.reset();
1229     },
1230     /**
1231      * Prefixes ``$node`` with floated spaces in order to indent it relative
1232      * to its own left margin/baseline
1233      *
1234      * @param {jQuery} $node jQuery object to indent
1235      * @param {Number} level current nesting level, >= 1
1236      * @returns {jQuery} the indentation node created
1237      */
1238     indent: function ($node, level) {
1239         return $('<span>')
1240                 .css({'float': 'left', 'white-space': 'pre'})
1241                 .text(new Array(level).join('   '))
1242                 .prependTo($node);
1243     },
1244     render_groups: function (datagroups) {
1245         var self = this;
1246         var placeholder = this.make_fragment();
1247         _(datagroups).each(function (group) {
1248             if (self.children[group.value]) {
1249                 self.records.proxy(group.value).reset();
1250                 delete self.children[group.value];
1251             }
1252             var child = self.children[group.value] = new instance.web.ListView.Groups(self.view, {
1253                 records: self.records.proxy(group.value),
1254                 options: self.options,
1255                 columns: self.columns
1256             });
1257             self.bind_child_events(child);
1258             child.datagroup = group;
1259
1260             var $row = child.$row = $('<tr>');
1261             if (group.openable && group.length) {
1262                 $row.click(function (e) {
1263                     if (!$row.data('open')) {
1264                         $row.data('open', true)
1265                             .find('span.ui-icon')
1266                                 .removeClass('ui-icon-triangle-1-e')
1267                                 .addClass('ui-icon-triangle-1-s');
1268                         child.open(self.point_insertion(e.currentTarget));
1269                     } else {
1270                         $row.removeData('open')
1271                             .find('span.ui-icon')
1272                                 .removeClass('ui-icon-triangle-1-s')
1273                                 .addClass('ui-icon-triangle-1-e');
1274                         child.close();
1275                     }
1276                 });
1277             }
1278             placeholder.appendChild($row[0]);
1279
1280             var $group_column = $('<th class="oe-group-name">').appendTo($row);
1281             // Don't fill this if group_by_no_leaf but no group_by
1282             if (group.grouped_on) {
1283                 var row_data = {};
1284                 row_data[group.grouped_on] = group;
1285                 var group_column = _(self.columns).detect(function (column) {
1286                     return column.id === group.grouped_on; });
1287                 try {
1288                     $group_column.html(instance.web.format_cell(
1289                         row_data, group_column, {
1290                             value_if_empty: _t("Undefined"),
1291                             process_modifiers: false
1292                     }));
1293                 } catch (e) {
1294                     $group_column.html(row_data[group_column.id].value);
1295                 }
1296                 if (group.length && group.openable) {
1297                     // Make openable if not terminal group & group_by_no_leaf
1298                     $group_column.prepend('<span class="ui-icon ui-icon-triangle-1-e" style="float: left;">');
1299                 } else {
1300                     // Kinda-ugly hack: jquery-ui has no "empty" icon, so set
1301                     // wonky background position to ensure nothing is displayed
1302                     // there but the rest of the behavior is ui-icon's
1303                     $group_column.prepend('<span class="ui-icon" style="float: left; background-position: 150px 150px">');
1304                 }
1305             }
1306             self.indent($group_column, group.level);
1307             // count column
1308             $('<td>').text(group.length).appendTo($row);
1309
1310             if (self.options.selectable) {
1311                 $row.append('<td>');
1312             }
1313             if (self.options.isClarkGable) {
1314                 $row.append('<td>');
1315             }
1316             _(self.columns).chain()
1317                 .filter(function (column) {return !column.invisible;})
1318                 .each(function (column) {
1319                     if (column.meta) {
1320                         // do not do anything
1321                     } else if (column.id in group.aggregates) {
1322                         var r = {};
1323                         r[column.id] = {value: group.aggregates[column.id]};
1324                         $('<td class="oe-number">')
1325                             .html(instance.web.format_cell(
1326                                 r, column, {process_modifiers: false}))
1327                             .appendTo($row);
1328                     } else {
1329                         $row.append('<td>');
1330                     }
1331                 });
1332             if (self.options.deletable) {
1333                 $row.append('<td class="oe-group-pagination">');
1334             }
1335         });
1336         return placeholder;
1337     },
1338     bind_child_events: function (child) {
1339         var $this = $(this),
1340              self = this;
1341         $(child).bind('selected', function (e) {
1342             // can have selections spanning multiple links
1343             var selection = self.get_selection();
1344             $this.trigger(e, [selection.ids, selection.records]);
1345         }).bind(this.passtrough_events, function (e) {
1346             // additional positional parameters are provided to trigger as an
1347             // Array, following the event type or event object, but are
1348             // provided to the .bind event handler as *args.
1349             // Convert our *args back into an Array in order to trigger them
1350             // on the group itself, so it can ultimately be forwarded wherever
1351             // it's supposed to go.
1352             var args = Array.prototype.slice.call(arguments, 1);
1353             $this.trigger.call($this, e, args);
1354         });
1355     },
1356     render_dataset: function (dataset) {
1357         var self = this,
1358             list = new instance.web.ListView.List(this, {
1359                 options: this.options,
1360                 columns: this.columns,
1361                 dataset: dataset,
1362                 records: this.records
1363             });
1364         this.bind_child_events(list);
1365
1366         var view = this.view,
1367            limit = view.limit(),
1368                d = new $.Deferred(),
1369             page = this.datagroup.openable ? this.page : view.page;
1370
1371         var fields = _.pluck(_.select(this.columns, function(x) {return x.tag == "field"}), 'name');
1372         var options = { offset: page * limit, limit: limit, context: {bin_size: true} };
1373         //TODO xmo: investigate why we need to put the setTimeout
1374         $.async_when().then(function() {dataset.read_slice(fields, options).then(function (records) {
1375             // FIXME: ignominious hacks, parents (aka form view) should not send two ListView#reload_content concurrently
1376             if (self.records.length) {
1377                 self.records.reset(null, {silent: true});
1378             }
1379             if (!self.datagroup.openable) {
1380                 view.configure_pager(dataset);
1381             } else {
1382                 if (dataset.size() == records.length) {
1383                     // only one page
1384                     self.$row.find('td.oe-group-pagination').empty();
1385                 } else {
1386                     var pages = Math.ceil(dataset.size() / limit);
1387                     self.$row
1388                         .find('.oe-pager-state')
1389                             .text(_.str.sprintf(_t("%(page)d/%(page_count)d"), {
1390                                 page: page + 1,
1391                                 page_count: pages
1392                             }))
1393                         .end()
1394                         .find('button[data-pager-action=previous]')
1395                             .attr('disabled', page === 0)
1396                         .end()
1397                         .find('button[data-pager-action=next]')
1398                             .attr('disabled', page === pages - 1);
1399                 }
1400             }
1401
1402             self.records.add(records, {silent: true});
1403             list.render();
1404             d.resolve(list);
1405         });});
1406         return d.promise();
1407     },
1408     setup_resequence_rows: function (list, dataset) {
1409         // drag and drop enabled if list is not sorted and there is a
1410         // "sequence" column in the view.
1411         if ((dataset.sort && dataset.sort())
1412             || !_(this.columns).any(function (column) {
1413                     return column.name === 'sequence'; })) {
1414             return;
1415         }
1416         // ondrop, move relevant record & fix sequences
1417         list.$current.sortable({
1418             axis: 'y',
1419             items: '> tr[data-id]',
1420             containment: 'parent',
1421             helper: 'clone',
1422             stop: function (event, ui) {
1423                 var to_move = list.records.get(ui.item.data('id')),
1424                     target_id = ui.item.prev().data('id'),
1425                     from_index = list.records.indexOf(to_move),
1426                     target = list.records.get(target_id);
1427                 if (list.records.at(from_index - 1) == target) {
1428                     return;
1429                 }
1430
1431                 list.records.remove(to_move);
1432                 var to = target_id ? list.records.indexOf(target) + 1 : 0;
1433                 list.records.add(to_move, { at: to });
1434
1435                 // resequencing time!
1436                 var record, index = to,
1437                     // if drag to 1st row (to = 0), start sequencing from 0
1438                     // (exclusive lower bound)
1439                     seq = to ? list.records.at(to - 1).get('sequence') : 0;
1440                 while (++seq, record = list.records.at(index++)) {
1441                     // write are independent from one another, so we can just
1442                     // launch them all at the same time and we don't really
1443                     // give a fig about when they're done
1444                     // FIXME: breaks on o2ms (e.g. Accounting > Financial
1445                     //        Accounting > Taxes > Taxes, child tax accounts)
1446                     //        when synchronous (without setTimeout)
1447                     (function (dataset, id, seq) {
1448                         $.async_when().then(function () {
1449                             dataset.write(id, {sequence: seq});
1450                         });
1451                     }(dataset, record.get('id'), seq));
1452                     record.set('sequence', seq);
1453                 }
1454
1455                 list.refresh_zebra();
1456             }
1457         });
1458     },
1459     render: function (post_render) {
1460         var self = this;
1461         var $element = $('<tbody>');
1462         this.elements = [$element[0]];
1463
1464         this.datagroup.list(
1465             _(this.view.visible_columns).chain()
1466                 .filter(function (column) { return column.tag === 'field' })
1467                 .pluck('name').value(),
1468             function (groups) {
1469                 $element[0].appendChild(
1470                     self.render_groups(groups));
1471                 if (post_render) { post_render(); }
1472             }, function (dataset) {
1473                 self.render_dataset(dataset).then(function (list) {
1474                     self.children[null] = list;
1475                     self.elements =
1476                         [list.$current.replaceAll($element)[0]];
1477                     self.setup_resequence_rows(list, dataset);
1478                     if (post_render) { post_render(); }
1479                 });
1480             });
1481         return $element;
1482     },
1483     /**
1484      * Returns the ids of all selected records for this group, and the records
1485      * themselves
1486      */
1487     get_selection: function () {
1488         var ids = [], records = [];
1489
1490         _(this.children)
1491             .each(function (child) {
1492                 var selection = child.get_selection();
1493                 ids.push.apply(ids, selection.ids);
1494                 records.push.apply(records, selection.records);
1495             });
1496
1497         return {ids: ids, records: records};
1498     },
1499     on_records_reset: function () {
1500         this.children = {};
1501         $(this.elements).remove();
1502     },
1503     get_records: function () {
1504         if (_(this.children).isEmpty()) {
1505             if (!this.datagroup.length) {
1506                 return;
1507             }
1508             return {
1509                 count: this.datagroup.length,
1510                 values: this.datagroup.aggregates
1511             }
1512         }
1513         return _(this.children).chain()
1514             .map(function (child) {
1515                 return child.get_records();
1516             }).flatten().value();
1517     }
1518 });
1519
1520 /**
1521  * @mixin Events
1522  */
1523 var Events = /** @lends Events# */{
1524     /**
1525      * @param {String} event event to listen to on the current object, null for all events
1526      * @param {Function} handler event handler to bind to the relevant event
1527      * @returns this
1528      */
1529     bind: function (event, handler) {
1530         var calls = this['_callbacks'] || (this._callbacks = {});
1531
1532         if (event in calls) {
1533             calls[event].push(handler);
1534         } else {
1535             calls[event] = [handler];
1536         }
1537         return this;
1538     },
1539     /**
1540      * @param {String} event event to unbind on the current object
1541      * @param {function} [handler] specific event handler to remove (otherwise unbind all handlers for the event)
1542      * @returns this
1543      */
1544     unbind: function (event, handler) {
1545         var calls = this._callbacks || {};
1546         if (!(event in calls)) { return this; }
1547         if (!handler) {
1548             delete calls[event];
1549         } else {
1550             var handlers = calls[event];
1551             handlers.splice(
1552                 _(handlers).indexOf(handler),
1553                 1);
1554         }
1555         return this;
1556     },
1557     /**
1558      * @param {String} event
1559      * @returns this
1560      */
1561     trigger: function (event) {
1562         var calls;
1563         if (!(calls = this._callbacks)) { return this; }
1564         var callbacks = (calls[event] || []).concat(calls[null] || []);
1565         for(var i=0, length=callbacks.length; i<length; ++i) {
1566             callbacks[i].apply(this, arguments);
1567         }
1568         return this;
1569     }
1570 };
1571 var Record = instance.web.Class.extend(/** @lends Record# */{
1572     /**
1573      * @constructs Record
1574      * @extends instance.web.Class
1575      * 
1576      * @mixes Events
1577      * @param {Object} [data]
1578      */
1579     init: function (data) {
1580         this.attributes = data || {};
1581     },
1582     /**
1583      * @param {String} key
1584      * @returns {Object}
1585      */
1586     get: function (key) {
1587         return this.attributes[key];
1588     },
1589     /**
1590      * @param key
1591      * @param value
1592      * @param {Object} [options]
1593      * @param {Boolean} [options.silent=false]
1594      * @returns {Record}
1595      */
1596     set: function (key, value, options) {
1597         options = options || {};
1598         var old_value = this.attributes[key];
1599         if (old_value === value) {
1600             return this;
1601         }
1602         this.attributes[key] = value;
1603         if (!options.silent) {
1604             this.trigger('change:' + key, this, value, old_value);
1605             this.trigger('change', this, key, value, old_value);
1606         }
1607         return this;
1608     },
1609     /**
1610      * Converts the current record to the format expected by form views:
1611      *
1612      * .. code-block:: javascript
1613      *
1614      *    data: {
1615      *         $fieldname: {
1616      *             value: $value
1617      *         }
1618      *     }
1619      *
1620      *
1621      * @returns {Object} record displayable in a form view
1622      */
1623     toForm: function () {
1624         var form_data = {}, attrs = this.attributes;
1625         for(var k in attrs) {
1626             form_data[k] = {value: attrs[k]};
1627         }
1628
1629         return {data: form_data};
1630     },
1631     /**
1632      * Converts the current record to a format expected by context evaluations
1633      * (identical to record.attributes, except m2o fields are their integer
1634      * value rather than a pair)
1635      */
1636     toContext: function () {
1637         var output = {}, attrs = this.attributes;
1638         for(var k in attrs) {
1639             var val = attrs[k];
1640             if (typeof val !== 'object') {
1641                 output[k] = val;
1642             } else if (val instanceof Array) {
1643                 output[k] = val[0];
1644             } else {
1645                 throw new Error("Can't convert value " + val + " to context");
1646             }
1647         }
1648         return output;
1649     }
1650 });
1651 Record.include(Events);
1652 var Collection = instance.web.Class.extend(/** @lends Collection# */{
1653     /**
1654      * Smarter collections, with events, very strongly inspired by Backbone's.
1655      *
1656      * Using a "dumb" array of records makes synchronization between the
1657      * various serious 
1658      *
1659      * @constructs Collection
1660      * @extends instance.web.Class
1661      * 
1662      * @mixes Events
1663      * @param {Array} [records] records to initialize the collection with
1664      * @param {Object} [options]
1665      */
1666     init: function (records, options) {
1667         options = options || {};
1668         _.bindAll(this, '_onRecordEvent');
1669         this.length = 0;
1670         this.records = [];
1671         this._byId = {};
1672         this._proxies = {};
1673         this._key = options.key;
1674         this._parent = options.parent;
1675
1676         if (records) {
1677             this.add(records);
1678         }
1679     },
1680     /**
1681      * @param {Object|Array} record
1682      * @param {Object} [options]
1683      * @param {Number} [options.at]
1684      * @param {Boolean} [options.silent=false]
1685      * @returns this
1686      */
1687     add: function (record, options) {
1688         options = options || {};
1689         var records = record instanceof Array ? record : [record];
1690
1691         for(var i=0, length=records.length; i<length; ++i) {
1692             var instance_ = (records[i] instanceof Record) ? records[i] : new Record(records[i]);
1693             instance_.bind(null, this._onRecordEvent);
1694             this._byId[instance_.get('id')] = instance_;
1695             if (options.at == undefined) {
1696                 this.records.push(instance_);
1697                 if (!options.silent) {
1698                     this.trigger('add', this, instance_, this.records.length-1);
1699                 }
1700             } else {
1701                 var insertion_index = options.at + i;
1702                 this.records.splice(insertion_index, 0, instance_);
1703                 if (!options.silent) {
1704                     this.trigger('add', this, instance_, insertion_index);
1705                 }
1706             }
1707             this.length++;
1708         }
1709         return this;
1710     },
1711
1712     /**
1713      * Get a record by its index in the collection, can also take a group if
1714      * the collection is not degenerate
1715      *
1716      * @param {Number} index
1717      * @param {String} [group]
1718      * @returns {Record|undefined}
1719      */
1720     at: function (index, group) {
1721         if (group) {
1722             var groups = group.split('.');
1723             return this._proxies[groups[0]].at(index, groups.join('.'));
1724         }
1725         return this.records[index];
1726     },
1727     /**
1728      * Get a record by its database id
1729      *
1730      * @param {Number} id
1731      * @returns {Record|undefined}
1732      */
1733     get: function (id) {
1734         if (!_(this._proxies).isEmpty()) {
1735             var record = null;
1736             _(this._proxies).detect(function (proxy) {
1737                 return record = proxy.get(id);
1738             });
1739             return record;
1740         }
1741         return this._byId[id];
1742     },
1743     /**
1744      * Builds a proxy (insert/retrieve) to a subtree of the collection, by
1745      * the subtree's group
1746      *
1747      * @param {String} section group path section
1748      * @returns {Collection}
1749      */
1750     proxy: function (section) {
1751         return this._proxies[section] = new Collection(null, {
1752             parent: this,
1753             key: section
1754         }).bind(null, this._onRecordEvent);
1755     },
1756     /**
1757      * @param {Array} [records]
1758      * @returns this
1759      */
1760     reset: function (records, options) {
1761         options = options || {};
1762         _(this._proxies).each(function (proxy) {
1763             proxy.reset();
1764         });
1765         this._proxies = {};
1766         _(this.records).invoke('unbind', null, this._onRecordEvent);
1767         this.length = 0;
1768         this.records = [];
1769         this._byId = {};
1770         if (records) {
1771             this.add(records);
1772         }
1773         if (!options.silent) {
1774             this.trigger('reset', this);
1775         }
1776         return this;
1777     },
1778     /**
1779      * Removes the provided record from the collection
1780      *
1781      * @param {Record} record
1782      * @returns this
1783      */
1784     remove: function (record) {
1785         var index = _(this.records).indexOf(record);
1786         if (index === -1) {
1787             _(this._proxies).each(function (proxy) {
1788                 proxy.remove(record);
1789             });
1790             return this;
1791         }
1792
1793         record.unbind(null, this._onRecordEvent);
1794         this.records.splice(index, 1);
1795         delete this._byId[record.get('id')];
1796         this.length--;
1797         this.trigger('remove', record, this);
1798         return this;
1799     },
1800
1801     _onRecordEvent: function (event, record, options) {
1802         // don't propagate reset events
1803         if (event === 'reset') { return; }
1804         this.trigger.apply(this, arguments);
1805     },
1806
1807     // underscore-type methods
1808     each: function (callback) {
1809         for(var section in this._proxies) {
1810             if (this._proxies.hasOwnProperty(section)) {
1811                 this._proxies[section].each(callback);
1812             }
1813         }
1814         for(var i=0; i<this.length; ++i) {
1815             callback(this.records[i]);
1816         }
1817     },
1818     map: function (callback) {
1819         var results = [];
1820         this.each(function (record) {
1821             results.push(callback(record));
1822         });
1823         return results;
1824     },
1825     pluck: function (fieldname) {
1826         return this.map(function (record) {
1827             return record.get(fieldname);
1828         });
1829     },
1830     indexOf: function (record) {
1831         return _(this.records).indexOf(record);
1832     }
1833 });
1834 Collection.include(Events);
1835 instance.web.list = {
1836     Events: Events,
1837     Record: Record,
1838     Collection: Collection
1839 }
1840 };
1841 // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax: