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