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