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