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