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