[FIX] sorting list views
[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.list = {
4     /**
5      * Formats the rendring of a given value based on its field type
6      *
7      * @param {Object} row_data record whose values should be displayed in the cell
8      * @param {Object} column column descriptor
9      * @param {"button"|"field"} column.tag base control type
10      * @param {String} column.type widget type for a field control
11      * @param {String} [column.string] button label
12      * @param {String} [column.icon] button icon
13      * @param {String} [value_if_empty=''] what to display if the field's value is ``false``
14      */
15     render_cell: function (row_data, column, value_if_empty) {
16         var attrs = column.modifiers_for(row_data);
17         if (attrs.invisible) { return ''; }
18         if (column.tag === 'button') {
19             return [
20                 '<button type="button" title="', column.string || '', '">',
21                     '<img src="/base/static/src/img/icons/', column.icon, '.png"',
22                         ' alt="', column.string || '', '"/>',
23                 '</button>'
24             ].join('')
25         }
26
27         var record = row_data[column.id];
28         if (record.value === false) {
29             return value_if_empty === undefined ?  '' : value_if_empty;
30         }
31         switch (column.widget || column.type) {
32             case 'many2one':
33                 // name_get value format
34                 return record.value[1];
35             case 'float_time':
36                 return _.sprintf("%02d:%02d",
37                         Math.floor(record.value),
38                         Math.round((record.value % 1) * 60));
39             case 'progressbar':
40                 return _.sprintf(
41                     '<progress value="%.2f" max="100.0">%.2f%%</progress>',
42                         record.value, record.value);
43             default:
44                 return record.value;
45         }
46     }
47 };
48 openerp.base.ListView = openerp.base.View.extend( /** @lends openerp.base.ListView# */ {
49     defaults: {
50         // records can be selected one by one
51         'selectable': true,
52         // list rows can be deleted
53         'deletable': true,
54         // whether the column headers should be displayed
55         'header': true,
56         // display addition button, with that label
57         'addable': "New",
58         // whether the list view can be sorted, note that once a view has been
59         // sorted it can not be reordered anymore
60         'sortable': true,
61         // whether the view rows can be reordered (via vertical drag & drop)
62         'reorderable': true
63     },
64     /**
65      * Core class for list-type displays.
66      *
67      * As a view, needs a number of view-related parameters to be correctly
68      * instantiated, provides options and overridable methods for behavioral
69      * customization.
70      *
71      * See constructor parameters and method documentations for information on
72      * the default behaviors and possible options for the list view.
73      *
74      * @constructs
75      * @param view_manager
76      * @param session An OpenERP session object
77      * @param element_id the id of the DOM elements this view should link itself to
78      * @param {openerp.base.DataSet} dataset the dataset the view should work with
79      * @param {String} view_id the listview's identifier, if any
80      * @param {Object} options A set of options used to configure the view
81      * @param {Boolean} [options.selectable=true] determines whether view rows are selectable (e.g. via a checkbox)
82      * @param {Boolean} [options.header=true] should the list's header be displayed
83      * @param {Boolean} [options.deletable=true] are the list rows deletable
84      * @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.
85      * @param {Boolean} [options.sortable=true] is it possible to sort the table by clicking on column headers
86      * @param {Boolean} [options.reorderable=true] is it possible to reorder list rows
87      *
88      * @borrows openerp.base.ActionExecutor#execute_action as #execute_action
89      */
90     init: function(view_manager, session, element_id, dataset, view_id, options) {
91         this._super(session, element_id);
92         this.view_manager = view_manager || new openerp.base.NullViewManager();
93         this.dataset = dataset;
94         this.model = dataset.model;
95         this.view_id = view_id;
96
97         this.columns = [];
98
99         this.options = _.extend({}, this.defaults, options || {});
100         this.flags =  this.view_manager.flags || {};
101
102         this.set_groups(new openerp.base.ListView.Groups(this));
103         
104         if (this.dataset instanceof openerp.base.DataSetStatic) {
105             this.groups.datagroup = new openerp.base.StaticDataGroup(this.dataset);
106         }
107
108         this.page = 0;
109     },
110     /**
111      * Retrieves the view's number of records per page (|| section)
112      *
113      * options > defaults > view_manager.action.limit > indefinite
114      *
115      * @returns {Number|null}
116      */
117     limit: function () {
118         if (this._limit === undefined) {
119             this._limit = (this.options.limit
120                         || this.defaults.limit
121                         || (this.view_manager.action || {}).limit
122                         || null);
123         }
124         return this._limit;
125     },
126     /**
127      * Set a custom Group construct as the root of the List View.
128      *
129      * @param {openerp.base.ListView.Groups} groups
130      */
131     set_groups: function (groups) {
132         var self = this;
133         if (this.groups) {
134             $(this.groups).unbind("selected deleted action row_link");
135             delete this.groups;
136         }
137
138         this.groups = groups;
139         $(this.groups).bind({
140             'selected': function (e, ids, records) {
141                 self.do_select(ids, records);
142             },
143             'deleted': function (e, ids) {
144                 self.do_delete(ids);
145             },
146             'action': function (e, action_name, id, callback) {
147                 self.do_action(action_name, id, callback);
148             },
149             'row_link': function (e, id, dataset) {
150                 self.do_activate_record(dataset.index, id, dataset);
151             }
152         });
153     },
154     /**
155      * View startup method, the default behavior is to set the ``oe-listview``
156      * class on its root element and to perform an RPC load call.
157      *
158      * @returns {$.Deferred} loading promise
159      */
160     start: function() {
161         this.$element.addClass('oe-listview');
162         return this.reload_view();
163     },
164     /**
165      * Called after loading the list view's description, sets up such things
166      * as the view table's columns, renders the table itself and hooks up the
167      * various table-level and row-level DOM events (action buttons, deletion
168      * buttons, selection of records, [New] button, selection of a given
169      * record, ...)
170      *
171      * Sets up the following:
172      *
173      * * Processes arch and fields to generate a complete field descriptor for each field
174      * * Create the table itself and allocate visible columns
175      * * Hook in the top-level (header) [New|Add] and [Delete] button
176      * * Sets up showing/hiding the top-level [Delete] button based on records being selected or not
177      * * Sets up event handlers for action buttons and per-row deletion button
178      * * Hooks global callback for clicking on a row
179      * * Sets up its sidebar, if any
180      *
181      * @param {Object} data wrapped fields_view_get result
182      * @param {Object} data.fields_view fields_view_get result (processed)
183      * @param {Object} data.fields_view.fields mapping of fields for the current model
184      * @param {Object} data.fields_view.arch current list view descriptor
185      * @param {Boolean} grouped Is the list view grouped
186      */
187     on_loaded: function(data, grouped) {
188         var self = this;
189         this.fields_view = data.fields_view;
190         //this.log(this.fields_view);
191         this.name = "" + this.fields_view.arch.attrs.string;
192
193         this.setup_columns(this.fields_view.fields, grouped);
194
195         if (!this.fields_view.sorted) { this.fields_view.sorted = {}; }
196
197         this.$element.html(QWeb.render("ListView", this));
198
199         // Head hook
200         this.$element.find('.oe-list-add')
201                 .click(this.do_add_record)
202                 .attr('disabled', grouped && this.options.editable);
203         this.$element.find('.oe-list-delete')
204                 .attr('disabled', true)
205                 .click(this.do_delete_selected);
206         this.$element.find('thead').delegate('th[data-id]', 'click', function (e) {
207             e.stopPropagation();
208
209             self.dataset.sort($(this).data('id'));
210
211             // TODO: should only reload content (and set the right column to a sorted display state)
212             self.reload_content();
213         });
214
215         this.$element.find('.oe-list-pager')
216             .delegate('button', 'click', function () {
217                 var $this = $(this);
218                 switch ($this.data('pager-action')) {
219                     case 'first':
220                         self.page = 0; break;
221                     case 'last':
222                         self.page = Math.floor(
223                             self.dataset.ids.length / self.limit());
224                         break;
225                     case 'next':
226                         self.page += 1; break;
227                     case 'previous':
228                         self.page -= 1; break;
229                 }
230                 self.reload_content();
231             }).find('.oe-pager-state')
232                 .click(function (e) {
233                     e.stopPropagation();
234                     var $this = $(this);
235
236                     var $select = $('<select>')
237                         .appendTo($this.empty())
238                         .click(function (e) {e.stopPropagation();})
239                         .append('<option value="80">80</option>' +
240                                 '<option value="100">100</option>' +
241                                 '<option value="200">200</option>' +
242                                 '<option value="500">500</option>' +
243                                 '<option value="NaN">Unlimited</option>')
244                         .change(function () {
245                             var val = parseInt($select.val(), 10);
246                             self._limit = (isNaN(val) ? null : val);
247                             self.page = 0;
248                             self.reload_content();
249                         })
250                         .val(self._limit || 'NaN');
251                 });
252
253         this.view_manager.sidebar.set_toolbar(data.fields_view.toolbar);
254     },
255     /**
256      * Configures the ListView pager based on the provided dataset's information
257      *
258      * Horrifying side-effect: sets the dataset's data on this.dataset?
259      *
260      * @param {openerp.base.DataSet} dataset
261      */
262     configure_pager: function (dataset) {
263         this.dataset.ids = dataset.ids;
264
265         var limit = this.limit(),
266             total = dataset.ids.length,
267             first = (this.page * limit),
268             last;
269         if (!limit || (total - first) < limit) {
270             last = total;
271         } else {
272             last = first + limit;
273         }
274         this.$element.find('span.oe-pager-state').empty().text(_.sprintf(
275             "[%d to %d] of %d", first + 1, last, total));
276
277         this.$element
278             .find('button[data-pager-action=first], button[data-pager-action=previous]')
279                 .attr('disabled', this.page === 0)
280             .end()
281             .find('button[data-pager-action=last], button[data-pager-action=next]')
282                 .attr('disabled', last === total);
283     },
284     /**
285      * Sets up the listview's columns: merges view and fields data, move
286      * grouped-by columns to the front of the columns list and make them all
287      * visible.
288      *
289      * @param {Object} fields fields_view_get's fields section
290      * @param {Boolean} [grouped] Should the grouping columns (group and count) be displayed
291      */
292     setup_columns: function (fields, grouped) {
293         var domain_computer = openerp.base.form.compute_domain;
294
295         var noop = function () { return {}; };
296         var field_to_column = function (field) {
297             var name = field.attrs.name;
298             var column = _.extend({id: name, tag: field.tag},
299                     field.attrs, fields[name]);
300             // modifiers computer
301             if (column.modifiers) {
302                 var modifiers = JSON.parse(column.modifiers);
303                 column.modifiers_for = function (fields) {
304                     if (!modifiers.invisible) {
305                         return {};
306                     }
307                     return {
308                         'invisible': domain_computer(modifiers.invisible, fields)
309                     };
310                 };
311                 if (modifiers['tree_invisible']) {
312                     column.invisible = '1';
313                 }
314             } else {
315                 column.modifiers_for = noop;
316             }
317             return column;
318         };
319
320         this.columns.splice(0, this.columns.length);
321         this.columns.push.apply(
322                 this.columns,
323                 _(this.fields_view.arch.children).map(field_to_column));
324         if (grouped) {
325             this.columns.unshift({
326                 id: '_group', tag: '', string: "Group", meta: true,
327                 modifiers_for: function () { return {}; }
328             }, {
329                 id: '_count', tag: '', string: '#', meta: true,
330                 modifiers_for: function () { return {}; }
331             });
332         }
333
334         this.visible_columns = _.filter(this.columns, function (column) {
335             return column.invisible !== '1';
336         });
337
338         this.aggregate_columns = _(this.visible_columns)
339             .map(function (column) {
340                 if (column.type !== 'integer' && column.type !== 'float') {
341                     return {};
342                 }
343                 var aggregation_func = column['group_operator'] || 'sum';
344
345                 if (!column[aggregation_func]) {
346                     return {};
347                 }
348
349                 return {
350                     field: column.id,
351                     type: column.type,
352                     'function': aggregation_func,
353                     label: column[aggregation_func]
354                 };
355             });
356     },
357     /**
358      * Used to handle a click on a table row, if no other handler caught the
359      * event.
360      *
361      * The default implementation asks the list view's view manager to switch
362      * to a different view (by calling
363      * :js:func:`~openerp.base.ViewManager.on_mode_switch`), using the
364      * provided record index (within the current list view's dataset).
365      *
366      * If the index is null, ``switch_to_record`` asks for the creation of a
367      * new record.
368      *
369      * @param {Number|void} index the record index (in the current dataset) to switch to
370      * @param {String} [view="form"] the view type to switch to
371      */
372     select_record:function (index, view) {
373         view = view || 'form';
374         this.dataset.index = index;
375         _.delay(_.bind(function () {
376             if(this.view_manager) {
377                 this.view_manager.on_mode_switch(view);
378             }
379         }, this));
380     },
381     do_show: function () {
382         this.$element.show();
383         if (this.hidden) {
384             this.$element.find('table').append(
385                 this.groups.apoptosis().render());
386             this.hidden = false;
387         }
388         this.view_manager.sidebar.do_refresh(true);
389     },
390     do_hide: function () {
391         this.$element.hide();
392         this.hidden = true;
393     },
394     /**
395      * Reloads the list view based on the current settings (dataset & al)
396      *
397      * @param {Boolean} [grouped] Should the list be displayed grouped
398      */
399     reload_view: function (grouped) {
400         var self = this;
401         var callback = function (field_view_get) {
402             self.on_loaded(field_view_get, grouped);
403         };
404         if (this.embedded_view) {
405             return $.Deferred().then(callback).resolve({fields_view: this.embedded_view});
406         } else {
407             return this.rpc('/base/listview/load', {
408                 model: this.model,
409                 view_id: this.view_id,
410                 context: this.dataset.get_context(),
411                 toolbar: !!this.flags.sidebar
412             }, callback);
413         }
414     },
415     /**
416      * re-renders the content of the list view
417      */
418     reload_content: function () {
419         this.$element.find('table').append(
420             this.groups.apoptosis().render(
421                 $.proxy(this, 'compute_aggregates')));
422     },
423     /**
424      * Event handler for a search, asks for the computation/folding of domains
425      * and contexts (and group-by), then reloads the view's content.
426      *
427      * @param {Array} domains a sequence of literal and non-literal domains
428      * @param {Array} contexts a sequence of literal and non-literal contexts
429      * @param {Array} groupbys a sequence of literal and non-literal group-by contexts
430      * @returns {$.Deferred} fold request evaluation promise
431      */
432     do_search: function (domains, contexts, groupbys) {
433         return this.rpc('/base/session/eval_domain_and_context', {
434             domains: domains,
435             contexts: contexts,
436             group_by_seq: groupbys
437         }, $.proxy(this, 'do_actual_search'));
438     },
439     /**
440      * Handler for the result of eval_domain_and_context, actually perform the
441      * searching
442      *
443      * @param {Object} results results of evaluating domain and process for a search
444      */
445     do_actual_search: function (results) {
446         this.dataset.context = results.context;
447         this.dataset.domain = results.domain;
448         this.groups.datagroup = new openerp.base.DataGroup(
449             this.session, this.model,
450             results.domain, results.context,
451             results.group_by);
452         this.groups.datagroup.sort = this.dataset._sort;
453
454         if (_.isEmpty(results.group_by) && !results.context['group_by_no_leaf']) {
455             results.group_by = null;
456         }
457
458         this.reload_view(!!results.group_by).then(
459             $.proxy(this, 'reload_content'));
460     },
461     /**
462      * Handles the signal to delete lines from the records list
463      *
464      * @param {Array} ids the ids of the records to delete
465      */
466     do_delete: function (ids) {
467         if (!ids.length) {
468             return;
469         }
470         var self = this;
471         return $.when(this.dataset.unlink(ids)).then(function () {
472             self.groups.drop_records(ids);
473         });
474     },
475     /**
476      * Handles the signal indicating that a new record has been selected
477      *
478      * @param {Array} ids selected record ids
479      * @param {Array} records selected record values
480      */
481     do_select: function (ids, records) {
482         this.$element.find('.oe-list-delete')
483             .attr('disabled', !ids.length);
484
485         if (!records.length) {
486             this.compute_aggregates();
487             return;
488         }
489         this.compute_aggregates(_(records).map(function (record) {
490             return {count: 1, values: record};
491         }));
492     },
493     /**
494      * Handles action button signals on a record
495      *
496      * @param {String} name action name
497      * @param {Object} id id of the record the action should be called on
498      * @param {Function} callback should be called after the action is executed, if non-null
499      */
500     do_action: function (name, id, callback) {
501         var action = _.detect(this.columns, function (field) {
502             return field.name === name;
503         });
504         if (!action) { return; }
505         this.execute_action(action, this.dataset, this.session.action_manager, id, callback);
506     },
507     /**
508      * Handles the activation of a record (clicking on it)
509      *
510      * @param {Number} index index of the record in the dataset
511      * @param {Object} id identifier of the activated record
512      * @param {openerp.base.DataSet} dataset dataset in which the record is available (may not be the listview's dataset in case of nested groups)
513      */
514     do_activate_record: function (index, id, dataset) {
515         var self = this;
516         _.extend(this.dataset, {
517             domain: dataset.domain,
518             context: dataset.get_context()
519         }).read_slice([], 0, false, function () {
520             self.select_record(index);
521         });
522     },
523     /**
524      * Handles signal for the addition of a new record (can be a creation,
525      * can be the addition from a remote source, ...)
526      *
527      * The default implementation is to switch to a new record on the form view
528      */
529     do_add_record: function () {
530         this.select_record(null);
531     },
532     /**
533      * Handles deletion of all selected lines
534      */
535     do_delete_selected: function () {
536         this.do_delete(this.groups.get_selection().ids);
537     },
538     /**
539      * Computes the aggregates for the current list view, either on the
540      * records provided or on the records of the internal
541      * :js:class:`~openerp.base.ListView.Group`, by calling
542      * :js:func:`~openerp.base.ListView.group.get_records`.
543      *
544      * Then displays the aggregates in the table through
545      * :js:method:`~openerp.base.ListView.display_aggregates`.
546      *
547      * @param {Array} [records]
548      */
549     compute_aggregates: function (records) {
550         var columns = _(this.aggregate_columns).filter(function (column) {
551             return column['function']; });
552
553         if (_.isEmpty(columns)) { return; }
554
555         if (_.isEmpty(records)) {
556             records = this.groups.get_records();
557         }
558
559         var count = 0, sums = {};
560         _(columns).each(function (column) { sums[column.field] = 0; });
561         _(records).each(function (record) {
562             count += record.count || 1;
563             _(columns).each(function (column) {
564                 var field = column.field;
565                 switch (column['function']) {
566                     case 'sum':
567                         sums[field] += record.values[field];
568                         break;
569                     case 'avg':
570                         sums[field] += record.count * record.values[field];
571                         break;
572                 }
573             });
574         });
575
576         var aggregates = {};
577         _(columns).each(function (column) {
578             var field = column.field;
579             switch (column['function']) {
580                 case 'sum':
581                     aggregates[field] = sums[field];
582                     break;
583                 case 'avg':
584                     aggregates[field] = sums[field] / count;
585                     break;
586             }
587         });
588
589         this.display_aggregates(aggregates);
590     },
591     display_aggregates: function (aggregation) {
592         var $footer_cells = this.$element.find('.oe-list-footer');
593         _(this.aggregate_columns).each(function (column) {
594             if (!column['function']) {
595                 return;
596             }
597             var pattern = (column.type == 'integer') ? '%d' : '%.2f';
598             $footer_cells.filter(_.sprintf('[data-field=%s]', column.field))
599                 .text(_.sprintf(pattern, aggregation[column.field]));
600         });
601     }
602     // TODO: implement reorder (drag and drop rows)
603 });
604 openerp.base.ListView.List = Class.extend( /** @lends openerp.base.ListView.List# */{
605     /**
606      * List display for the ListView, handles basic DOM events and transforms
607      * them in the relevant higher-level events, to which the list view (or
608      * other consumers) can subscribe.
609      *
610      * Events on this object are registered via jQuery.
611      *
612      * Available events:
613      *
614      * `selected`
615      *   Triggered when a row is selected (using check boxes), provides an
616      *   array of ids of all the selected records.
617      * `deleted`
618      *   Triggered when deletion buttons are hit, provide an array of ids of
619      *   all the records being marked for suppression.
620      * `action`
621      *   Triggered when an action button is clicked, provides two parameters:
622      *
623      *   * The name of the action to execute (as a string)
624      *   * The id of the record to execute the action on
625      * `row_link`
626      *   Triggered when a row of the table is clicked, provides the index (in
627      *   the rows array) and id of the selected record to the handle function.
628      *
629      * @constructs
630      * @param {Object} opts display options, identical to those of :js:class:`openerp.base.ListView`
631      */
632     init: function (group, opts) {
633         var self = this;
634         this.group = group;
635         this.view = group.view;
636
637         this.options = opts.options;
638         this.columns = opts.columns;
639         this.dataset = opts.dataset;
640         this.rows = opts.rows;
641
642         this.$_element = $('<tbody class="ui-widget-content">')
643             .appendTo(document.body)
644             .delegate('th.oe-record-selector', 'click', function (e) {
645                 e.stopPropagation();
646                 var selection = self.get_selection();
647                 $(self).trigger(
648                         'selected', [selection.ids, selection.records]);
649             })
650             .delegate('td.oe-record-delete button', 'click', function (e) {
651                 e.stopPropagation();
652                 var $row = $(e.target).closest('tr');
653                 $(self).trigger('deleted', [[self.row_id($row)]]);
654             })
655             .delegate('td.oe-field-cell button', 'click', function (e) {
656                 e.stopPropagation();
657                 var $target = $(e.currentTarget),
658                       field = $target.closest('td').data('field'),
659                        $row = $target.closest('tr'),
660                   record_id = self.row_id($row),
661                       index = self.row_position($row);
662
663                 $(self).trigger('action', [field, record_id, function () {
664                     self.reload_record(index, true);
665                 }]);
666             })
667             .delegate('tr', 'click', function (e) {
668                 e.stopPropagation();
669                 self.dataset.index = self.row_position(e.currentTarget);
670                 self.row_clicked(e);
671             });
672     },
673     row_clicked: function () {
674         $(this).trigger(
675             'row_link',
676             [this.rows[this.dataset.index].data.id.value,
677              this.dataset]);
678     },
679     render: function () {
680         if (this.$current) {
681             this.$current.remove();
682         }
683         this.$current = this.$_element.clone(true);
684         this.$current.empty().append(
685             QWeb.render('ListView.rows', _.extend({
686                 render_cell: openerp.base.list.render_cell}, this)));
687     },
688     /**
689      * Gets the ids of all currently selected records, if any
690      * @returns {Object} object with the keys ``ids`` and ``records``, holding respectively the ids of all selected records and the records themselves.
691      */
692     get_selection: function () {
693         if (!this.options.selectable) {
694             return [];
695         }
696         var rows = this.rows;
697         var result = {ids: [], records: []};
698         this.$current.find('th.oe-record-selector input:checked')
699                 .closest('tr').each(function () {
700             var record = {};
701             _(rows[$(this).data('index')].data).each(function (obj, key) {
702                 record[key] = obj.value;
703             });
704             result.ids.push(record.id);
705             result.records.push(record);
706         });
707         return result;
708     },
709     /**
710      * Returns the index of the row in the list of rows.
711      *
712      * @param {Object} row the selected row
713      * @returns {Number} the position of the row in this.rows
714      */
715     row_position: function (row) {
716         return $(row).data('index');
717     },
718     /**
719      * Returns the identifier of the object displayed in the provided table
720      * row
721      *
722      * @param {Object} row the selected table row
723      * @returns {Number|String} the identifier of the row's object
724      */
725     row_id: function (row) {
726         return this.rows[this.row_position(row)].data.id.value;
727     },
728     /**
729      * Death signal, cleans up list
730      */
731     apoptosis: function () {
732         if (!this.$current) { return; }
733         this.$current.remove();
734         this.$current = null;
735         this.$_element.remove();
736     },
737     get_records: function () {
738         return _(this.rows).map(function (row) {
739             var record = {};
740             _(row.data).each(function (obj, key) {
741                 record[key] = obj.value;
742             });
743             return {count: 1, values: record};
744         });
745     },
746     /**
747      * Transforms a record from what is returned by a dataset read (a simple
748      * mapping of ``$fieldname: $value``) to the format expected by list rows
749      * and form views:
750      *
751      *    data: {
752      *         $fieldname: {
753      *             value: $value
754      *         }
755      *     }
756      *
757      * This format allows for the insertion of a bunch of metadata (names,
758      * colors, etc...)
759      *
760      * @param {Object} record original record, in dataset format
761      * @returns {Object} record displayable in a form or list view
762      */
763     transform_record: function (record) {
764         // TODO: colors handling
765         var form_data = {},
766           form_record = {data: form_data};
767
768         _(record).each(function (value, key) {
769             form_data[key] = {value: value};
770         });
771
772         return form_record;
773     },
774     /**
775      * Reloads the record at index ``row_index`` in the list's rows.
776      *
777      * By default, simply re-renders the record. If the ``fetch`` parameter is
778      * provided and ``true``, will first fetch the record anew.
779      *
780      * @param {Number} record_index index of the record to reload
781      * @param {Boolean} fetch fetches the record from remote before reloading it
782      */
783     reload_record: function (record_index, fetch) {
784         var self = this;
785         var read_p = null;
786         if (fetch) {
787             // save index to restore it later, if already set
788             var old_index = this.dataset.index;
789             this.dataset.index = record_index;
790             read_p = this.dataset.read_index(
791                 _.filter(_.pluck(this.columns, 'name'), _.identity),
792                 function (record) {
793                     var form_record = self.transform_record(record);
794                     self.rows.splice(record_index, 1, form_record);
795                     self.dataset.index = old_index;
796                 }
797             )
798         }
799
800         return $.when(read_p).then(function () {
801             self.$current.children().eq(record_index)
802                 .replaceWith(self.render_record(record_index)); })
803     },
804     /**
805      * Renders a list record to HTML
806      *
807      * @param {Number} record_index index of the record to render in ``this.rows``
808      * @returns {String} QWeb rendering of the selected record
809      */
810     render_record: function (record_index) {
811         return QWeb.render('ListView.row', {
812             columns: this.columns,
813             options: this.options,
814             row: this.rows[record_index],
815             row_parity: (record_index % 2 === 0) ? 'even' : 'odd',
816             row_index: record_index,
817             render_cell: openerp.base.list.render_cell
818         });
819     },
820     /**
821      * Stops displaying the records matching the provided ids.
822      *
823      * @param {Array} ids identifiers of the records to remove
824      */
825     drop_records: function (ids) {
826         var self = this;
827         _(this.rows).chain()
828               .map(function (record, index) {
829                 return {index: index, id: record.data.id.value};
830             }).filter(function (record) {
831                 return _(ids).contains(record.id);
832             }).reverse()
833               .each(function (record) {
834                 self.$current.find('tr:eq(' + record.index + ')').remove();
835                 self.rows.splice(record.index, 1);
836             })
837     }
838     // drag and drop
839 });
840 openerp.base.ListView.Groups = Class.extend( /** @lends openerp.base.ListView.Groups# */{
841     passtrough_events: 'action deleted row_link',
842     /**
843      * Grouped display for the ListView. Handles basic DOM events and interacts
844      * with the :js:class:`~openerp.base.DataGroup` bound to it.
845      *
846      * Provides events similar to those of
847      * :js:class:`~openerp.base.ListView.List`
848      */
849     init: function (view) {
850         this.view = view;
851         this.options = view.options;
852         this.columns = view.columns;
853         this.datagroup = null;
854
855         this.$row = null;
856         this.children = {};
857
858         this.page = 0;
859     },
860     make_fragment: function () {
861         return document.createDocumentFragment();
862     },
863     /**
864      * Returns a DOM node after which a new tbody can be inserted, so that it
865      * follows the provided row.
866      *
867      * Necessary to insert the result of a new group or list view within an
868      * existing groups render, without losing track of the groups's own
869      * elements
870      *
871      * @param {HTMLTableRowElement} row the row after which the caller wants to insert a body
872      * @returns {HTMLTableSectionElement} element after which a tbody can be inserted
873      */
874     point_insertion: function (row) {
875         var $row = $(row);
876         var red_letter_tboday = $row.closest('tbody')[0];
877
878         var $next_siblings = $row.nextAll();
879         if ($next_siblings.length) {
880             var $root_kanal = $('<tbody>').insertAfter(red_letter_tboday);
881
882             $root_kanal.append($next_siblings);
883             this.elements.splice(
884                 _.indexOf(this.elements, red_letter_tboday),
885                 0,
886                 $root_kanal[0]);
887         }
888         return red_letter_tboday;
889     },
890     make_paginator: function () {
891         var self = this;
892         var $prev = $('<button type="button" data-pager-action="previous">&lt;</button>')
893             .click(function (e) {
894                 e.stopPropagation();
895                 self.page -= 1;
896
897                 self.$row.closest('tbody').next()
898                     .replaceWith(self.render());
899             });
900         var $next = $('<button type="button" data-pager-action="next">&gt;</button>')
901             .click(function (e) {
902                 e.stopPropagation();
903                 self.page += 1;
904
905                 self.$row.closest('tbody').next()
906                     .replaceWith(self.render());
907             });
908         this.$row.children().last()
909             .append($prev)
910             .append('<span class="oe-pager-state"></span>')
911             .append($next);
912     },
913     open: function (point_insertion) {
914         this.render().insertAfter(point_insertion);
915         this.make_paginator();
916     },
917     close: function () {
918         this.$row.children().last().empty();
919         this.apoptosis();
920     },
921     /**
922      * Prefixes ``$node`` with floated spaces in order to indent it relative
923      * to its own left margin/baseline
924      *
925      * @param {jQuery} $node jQuery object to indent
926      * @param {Number} level current nesting level, >= 1
927      * @returns {jQuery} the indentation node created
928      */
929     indent: function ($node, level) {
930         return $('<span>')
931                 .css({'float': 'left', 'white-space': 'pre'})
932                 .text(new Array(level).join('   '))
933                 .prependTo($node);
934     },
935     render_groups: function (datagroups) {
936         var self = this;
937         var placeholder = this.make_fragment();
938         _(datagroups).each(function (group) {
939             if (self.children[group.value]) {
940                 self.children[group.value].apoptosis();
941                 delete self.children[group.value];
942             }
943             var child = self.children[group.value] = new openerp.base.ListView.Groups(self.view, {
944                 options: self.options,
945                 columns: self.columns
946             });
947             self.bind_child_events(child);
948             child.datagroup = group;
949
950             var $row = child.$row = $('<tr>');
951             if (group.openable) {
952                 $row.click(function (e) {
953                     if (!$row.data('open')) {
954                         $row.data('open', true)
955                             .find('span.ui-icon')
956                                 .removeClass('ui-icon-triangle-1-e')
957                                 .addClass('ui-icon-triangle-1-s');
958                         child.open(self.point_insertion(e.currentTarget));
959                     } else {
960                         $row.removeData('open')
961                             .find('span.ui-icon')
962                                 .removeClass('ui-icon-triangle-1-s')
963                                 .addClass('ui-icon-triangle-1-e');
964                         child.close();
965                     }
966                 });
967             }
968             placeholder.appendChild($row[0]);
969
970             var $group_column = $('<th class="oe-group-name">').appendTo($row);
971             // Don't fill this if group_by_no_leaf but no group_by
972             if (group.grouped_on) {
973                 var row_data = {};
974                 row_data[group.grouped_on] = group;
975                 var group_column = _(self.columns).detect(function (column) {
976                     return column.id === group.grouped_on; });
977                 $group_column.text(openerp.base.list.render_cell(
978                         row_data, group_column, "Undefined"
979                 ));
980                 if (group.openable) {
981                     // Make openable if not terminal group & group_by_no_leaf
982                     $group_column
983                         .prepend('<span class="ui-icon ui-icon-triangle-1-e" style="float: left;">');
984                 }
985             }
986             self.indent($group_column, group.level);
987             // count column
988             $('<td>').text(group.length).appendTo($row);
989
990             if (self.options.selectable) {
991                 $row.append('<td>');
992             }
993             _(self.columns).chain()
994                 .filter(function (column) {return !column.invisible;})
995                 .each(function (column) {
996                     if (column.meta) {
997                         // do not do anything
998                     } else if (column.id in group.aggregates) {
999                         var value = group.aggregates[column.id];
1000                         var format;
1001                         if (column.type === 'integer') {
1002                             format = "%.0f";
1003                         } else if (column.type === 'float') {
1004                             format = "%.2f";
1005                         }
1006                         $('<td>')
1007                             .text(_.sprintf(format, value))
1008                             .appendTo($row);
1009                     } else {
1010                         $row.append('<td>');
1011                     }
1012                 });
1013             if (self.options.deletable) {
1014                 $row.append('<td class="oe-group-pagination">');
1015             }
1016         });
1017         return placeholder;
1018     },
1019     bind_child_events: function (child) {
1020         var $this = $(this),
1021              self = this;
1022         $(child).bind('selected', function (e) {
1023             // can have selections spanning multiple links
1024             var selection = self.get_selection();
1025             $this.trigger(e, [selection.ids, selection.records]);
1026         }).bind(this.passtrough_events, function (e) {
1027             // additional positional parameters are provided to trigger as an
1028             // Array, following the event type or event object, but are
1029             // provided to the .bind event handler as *args.
1030             // Convert our *args back into an Array in order to trigger them
1031             // on the group itself, so it can ultimately be forwarded wherever
1032             // it's supposed to go.
1033             var args = Array.prototype.slice.call(arguments, 1);
1034             $this.trigger.call($this, e, args);
1035         });
1036     },
1037     render_dataset: function (dataset) {
1038         var rows = [],
1039             self = this,
1040             list = new openerp.base.ListView.List(this, {
1041                 options: this.options,
1042                 columns: this.columns,
1043                 dataset: dataset,
1044                 rows: rows
1045             });
1046         this.bind_child_events(list);
1047
1048         var view = this.view,
1049            limit = view.limit(),
1050                d = new $.Deferred(),
1051             page = this.datagroup.openable ? this.page : view.page;
1052
1053         dataset.read_slice(
1054             _.filter(_.pluck(_.select(this.columns, function(x) {return x.tag == "field";}), 'name'), _.identity),
1055             page * limit, limit,
1056             function (records) {
1057                 if (!self.datagroup.openable) {
1058                     view.configure_pager(dataset);
1059                 } else {
1060                     var pages = Math.ceil(dataset.ids.length / limit);
1061                     self.$row
1062                         .find('.oe-pager-state')
1063                             .text(_.sprintf('%d/%d', page + 1, pages))
1064                         .end()
1065                         .find('button[data-pager-action=previous]')
1066                             .attr('disabled', page === 0)
1067                         .end()
1068                         .find('button[data-pager-action=next]')
1069                             .attr('disabled', page === pages - 1);
1070                 }
1071
1072                 var form_records = _(records).map(
1073                     $.proxy(list, 'transform_record'));
1074
1075                 rows.splice(0, rows.length);
1076                 rows.push.apply(rows, form_records);
1077                 list.render();
1078                 d.resolve(list);
1079             });
1080         return d.promise();
1081     },
1082     render: function (post_render) {
1083         var self = this;
1084         var $element = $('<tbody>');
1085         this.elements = [$element[0]];
1086
1087         this.datagroup.list(
1088             _(this.view.visible_columns).chain()
1089                 .filter(function (column) { return column.tag === 'field' })
1090                 .pluck('name').value(),
1091             function (groups) {
1092                 $element[0].appendChild(
1093                     self.render_groups(groups));
1094                 if (post_render) { post_render(); }
1095             }, function (dataset) {
1096                 self.render_dataset(dataset).then(function (list) {
1097                     self.children[null] = list;
1098                     self.elements =
1099                         [list.$current.replaceAll($element)[0]];
1100                     if (post_render) { post_render(); }
1101                 });
1102             });
1103         return $element;
1104     },
1105     /**
1106      * Returns the ids of all selected records for this group, and the records
1107      * themselves
1108      */
1109     get_selection: function () {
1110         var ids = [], records = [];
1111
1112         _(this.children)
1113             .each(function (child) {
1114                 var selection = child.get_selection();
1115                 ids.push.apply(ids, selection.ids);
1116                 records.push.apply(records, selection.records);
1117             });
1118
1119         return {ids: ids, records: records};
1120     },
1121     apoptosis: function () {
1122         _(this.children).each(function (child) {
1123             child.apoptosis();
1124         });
1125         this.children = {};
1126         $(this.elements).remove();
1127         return this;
1128     },
1129     get_records: function () {
1130         if (_(this.children).isEmpty()) {
1131             return {
1132                 count: this.datagroup.length,
1133                 values: this.datagroup.aggregates
1134             }
1135         }
1136         return _(this.children).chain()
1137             .map(function (child) {
1138                 return child.get_records();
1139             }).flatten().value();
1140     },
1141     /**
1142      * Stops displaying the records with the linked ids, assumes these records
1143      * were deleted from the DB.
1144      *
1145      * This is the up-signal from the `deleted` event on groups and lists.
1146      *
1147      * @param {Array} ids list of identifier of the records to remove.
1148      */
1149     drop_records: function (ids) {
1150         _.each(this.children, function (child) {
1151             child.drop_records(ids);
1152         });
1153     }
1154 });
1155 };
1156
1157 // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax:
1158