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