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