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