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