[FIX] when reloading the view description thingie, the listview needs to send the...
[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      * @param {Object} [context] context to send the server while loading the view
356      */
357     reload_view: function (grouped, context) {
358         var self = this;
359         var callback = function (field_view_get) {
360             self.on_loaded(field_view_get, grouped);
361         };
362         if (this.embedded_view) {
363             return $.Deferred().then(callback).resolve({fields_view: this.embedded_view});
364         } else {
365             return this.rpc('/base/listview/load', {
366                 model: this.model,
367                 view_id: this.view_id,
368                 context: this.dataset.get_context(context),
369                 toolbar: this.options.sidebar
370             }, callback);
371         }
372     },
373     /**
374      * re-renders the content of the list view
375      */
376     reload_content: function () {
377         this.$element.find('.oe-listview-content').append(
378             this.groups.apoptosis().render(
379                 $.proxy(this, 'compute_aggregates')));
380     },
381     /**
382      * Event handler for a search, asks for the computation/folding of domains
383      * and contexts (and group-by), then reloads the view's content.
384      *
385      * @param {Array} domains a sequence of literal and non-literal domains
386      * @param {Array} contexts a sequence of literal and non-literal contexts
387      * @param {Array} groupbys a sequence of literal and non-literal group-by contexts
388      * @returns {$.Deferred} fold request evaluation promise
389      */
390     do_search: function (domains, contexts, groupbys) {
391         return this.rpc('/base/session/eval_domain_and_context', {
392             domains: domains,
393             contexts: contexts,
394             group_by_seq: groupbys
395         }, $.proxy(this, 'do_actual_search'));
396     },
397     /**
398      * Handler for the result of eval_domain_and_context, actually perform the
399      * searching
400      *
401      * @param {Object} results results of evaluating domain and process for a search
402      */
403     do_actual_search: function (results) {
404         this.groups.datagroup = new openerp.base.DataGroup(
405             this, this.model,
406             this.dataset.get_domain(results.domain),
407             this.dataset.get_context(results.context),
408             results.group_by);
409         this.groups.datagroup.sort = this.dataset._sort;
410
411         if (_.isEmpty(results.group_by) && !results.context['group_by_no_leaf']) {
412             results.group_by = null;
413         }
414
415         this.reload_view(!!results.group_by, results.context).then(
416             $.proxy(this, 'reload_content'));
417     },
418     /**
419      * Handles the signal to delete lines from the records list
420      *
421      * @param {Array} ids the ids of the records to delete
422      */
423     do_delete: function (ids) {
424         if (!ids.length) {
425             return;
426         }
427         var self = this;
428         return $.when(this.dataset.unlink(ids)).then(function () {
429             self.groups.drop_records(ids);
430             self.compute_aggregates();
431         });
432     },
433     /**
434      * Handles the signal indicating that a new record has been selected
435      *
436      * @param {Array} ids selected record ids
437      * @param {Array} records selected record values
438      */
439     do_select: function (ids, records) {
440         this.$element.find('.oe-list-delete')
441             .attr('disabled', !ids.length);
442
443         if (!records.length) {
444             this.compute_aggregates();
445             return;
446         }
447         this.compute_aggregates(_(records).map(function (record) {
448             return {count: 1, values: record};
449         }));
450     },
451     /**
452      * Handles action button signals on a record
453      *
454      * @param {String} name action name
455      * @param {Object} id id of the record the action should be called on
456      * @param {Function} callback should be called after the action is executed, if non-null
457      */
458     do_button_action: function (name, id, callback) {
459         var   self = this,
460             action = _.detect(this.columns, function (field) {
461             return field.name === name;
462         });
463         if (!action) { return; }
464         this.execute_action(action, this.dataset, id, function () {
465             $.when(callback.apply(this, arguments).then(function () {
466                 self.compute_aggregates();
467             }));
468         });
469     },
470     /**
471      * Handles the activation of a record (clicking on it)
472      *
473      * @param {Number} index index of the record in the dataset
474      * @param {Object} id identifier of the activated record
475      * @param {openerp.base.DataSet} dataset dataset in which the record is available (may not be the listview's dataset in case of nested groups)
476      */
477     do_activate_record: function (index, id, dataset) {
478         var self = this;
479         this.dataset.read_slice({
480                 context: dataset.get_context(),
481                 domain: dataset.get_domain()
482             }, function () {
483                 self.select_record(index);
484         });
485     },
486     /**
487      * Handles signal for the addition of a new record (can be a creation,
488      * can be the addition from a remote source, ...)
489      *
490      * The default implementation is to switch to a new record on the form view
491      */
492     do_add_record: function () {
493         this.select_record(null);
494     },
495     /**
496      * Handles deletion of all selected lines
497      */
498     do_delete_selected: function () {
499         this.do_delete(this.groups.get_selection().ids);
500     },
501     /**
502      * Computes the aggregates for the current list view, either on the
503      * records provided or on the records of the internal
504      * :js:class:`~openerp.base.ListView.Group`, by calling
505      * :js:func:`~openerp.base.ListView.group.get_records`.
506      *
507      * Then displays the aggregates in the table through
508      * :js:method:`~openerp.base.ListView.display_aggregates`.
509      *
510      * @param {Array} [records]
511      */
512     compute_aggregates: function (records) {
513         var columns = _(this.aggregate_columns).filter(function (column) {
514             return column['function']; });
515         if (_.isEmpty(columns)) { return; }
516
517         if (_.isEmpty(records)) {
518             records = this.groups.get_records();
519         }
520
521         var count = 0, sums = {};
522         _(columns).each(function (column) {
523             switch (column['function']) {
524                 case 'max':
525                     sums[column.id] = -Infinity;
526                     break;
527                 case 'min':
528                     sums[column.id] = Infinity;
529                     break;
530                 default:
531                     sums[column.id] = 0;
532             }
533         });
534         _(records).each(function (record) {
535             count += record.count || 1;
536             _(columns).each(function (column) {
537                 var field = column.id,
538                     value = record.values[field];
539                 switch (column['function']) {
540                     case 'sum':
541                         sums[field] += value;
542                         break;
543                     case 'avg':
544                         sums[field] += record.count * value;
545                         break;
546                     case 'min':
547                         if (sums[field] > value) {
548                             sums[field] = value;
549                         }
550                         break;
551                     case 'max':
552                         if (sums[field] < value) {
553                             sums[field] = value;
554                         }
555                         break;
556                 }
557             });
558         });
559
560         var aggregates = {};
561         _(columns).each(function (column) {
562             var field = column.id;
563             switch (column['function']) {
564                 case 'avg':
565                     aggregates[field] = {value: sums[field] / count};
566                     break;
567                 default:
568                     aggregates[field] = {value: sums[field]};
569             }
570         });
571
572         this.display_aggregates(aggregates);
573     },
574     display_aggregates: function (aggregation) {
575         var $footer_cells = this.$element.find('.oe-list-footer');
576         _(this.aggregate_columns).each(function (column) {
577             if (!column['function']) {
578                 return;
579             }
580
581             $footer_cells.filter(_.sprintf('[data-field=%s]', column.id))
582                 .html(openerp.base.format_cell(aggregation, column));
583         });
584     }
585     // TODO: implement reorder (drag and drop rows)
586 });
587 openerp.base.ListView.List = openerp.base.Class.extend( /** @lends openerp.base.ListView.List# */{
588     /**
589      * List display for the ListView, handles basic DOM events and transforms
590      * them in the relevant higher-level events, to which the list view (or
591      * other consumers) can subscribe.
592      *
593      * Events on this object are registered via jQuery.
594      *
595      * Available events:
596      *
597      * `selected`
598      *   Triggered when a row is selected (using check boxes), provides an
599      *   array of ids of all the selected records.
600      * `deleted`
601      *   Triggered when deletion buttons are hit, provide an array of ids of
602      *   all the records being marked for suppression.
603      * `action`
604      *   Triggered when an action button is clicked, provides two parameters:
605      *
606      *   * The name of the action to execute (as a string)
607      *   * The id of the record to execute the action on
608      * `row_link`
609      *   Triggered when a row of the table is clicked, provides the index (in
610      *   the rows array) and id of the selected record to the handle function.
611      *
612      * @constructs
613      * @param {Object} opts display options, identical to those of :js:class:`openerp.base.ListView`
614      */
615     init: function (group, opts) {
616         var self = this;
617         this.group = group;
618         this.view = group.view;
619         this.session = this.view.session;
620
621         this.options = opts.options;
622         this.columns = opts.columns;
623         this.dataset = opts.dataset;
624         this.rows = opts.rows;
625
626         this.$_element = $('<tbody class="ui-widget-content">')
627             .appendTo(document.body)
628             .delegate('th.oe-record-selector', 'click', function (e) {
629                 e.stopPropagation();
630                 var selection = self.get_selection();
631                 $(self).trigger(
632                         'selected', [selection.ids, selection.records]);
633             })
634             .delegate('td.oe-record-delete button', 'click', function (e) {
635                 e.stopPropagation();
636                 var $row = $(e.target).closest('tr');
637                 $(self).trigger('deleted', [[self.row_id($row)]]);
638             })
639             .delegate('td.oe-field-cell button', 'click', function (e) {
640                 e.stopPropagation();
641                 var $target = $(e.currentTarget),
642                       field = $target.closest('td').data('field'),
643                        $row = $target.closest('tr'),
644                   record_id = self.row_id($row),
645                       index = self.row_position($row);
646
647                 $(self).trigger('action', [field, record_id, function () {
648                     return self.reload_record(index, true);
649                 }]);
650             })
651             .delegate('tr', 'click', function (e) {
652                 e.stopPropagation();
653                 self.dataset.index = self.row_position(e.currentTarget);
654                 self.row_clicked(e);
655             });
656     },
657     row_clicked: function () {
658         $(this).trigger(
659             'row_link',
660             [this.rows[this.dataset.index].data.id.value,
661              this.dataset]);
662     },
663     render: function () {
664         if (this.$current) {
665             this.$current.remove();
666         }
667         this.$current = this.$_element.clone(true);
668         this.$current.empty().append(
669             QWeb.render('ListView.rows', _.extend({
670                 render_cell: openerp.base.format_cell}, this)));
671     },
672     /**
673      * Gets the ids of all currently selected records, if any
674      * @returns {Object} object with the keys ``ids`` and ``records``, holding respectively the ids of all selected records and the records themselves.
675      */
676     get_selection: function () {
677         if (!this.options.selectable) {
678             return [];
679         }
680         var rows = this.rows;
681         var result = {ids: [], records: []};
682         this.$current.find('th.oe-record-selector input:checked')
683                 .closest('tr').each(function () {
684             var record = {};
685             _(rows[$(this).data('index')].data).each(function (obj, key) {
686                 record[key] = obj.value;
687             });
688             result.ids.push(record.id);
689             result.records.push(record);
690         });
691         return result;
692     },
693     /**
694      * Returns the index of the row in the list of rows.
695      *
696      * @param {Object} row the selected row
697      * @returns {Number} the position of the row in this.rows
698      */
699     row_position: function (row) {
700         return $(row).data('index');
701     },
702     /**
703      * Returns the identifier of the object displayed in the provided table
704      * row
705      *
706      * @param {Object} row the selected table row
707      * @returns {Number|String} the identifier of the row's object
708      */
709     row_id: function (row) {
710         return this.rows[this.row_position(row)].data.id.value;
711     },
712     /**
713      * Death signal, cleans up list
714      */
715     apoptosis: function () {
716         if (!this.$current) { return; }
717         this.$current.remove();
718         this.$current = null;
719         this.$_element.remove();
720     },
721     get_records: function () {
722         return _(this.rows).map(function (row) {
723             var record = {};
724             _(row.data).each(function (obj, key) {
725                 record[key] = obj.value;
726             });
727             return {count: 1, values: record};
728         });
729     },
730     /**
731      * Transforms a record from what is returned by a dataset read (a simple
732      * mapping of ``$fieldname: $value``) to the format expected by list rows
733      * and form views:
734      *
735      *    data: {
736      *         $fieldname: {
737      *             value: $value
738      *         }
739      *     }
740      *
741      * This format allows for the insertion of a bunch of metadata (names,
742      * colors, etc...)
743      *
744      * @param {Object} record original record, in dataset format
745      * @returns {Object} record displayable in a form or list view
746      */
747     transform_record: function (record) {
748         // TODO: colors handling
749         var form_data = {},
750           form_record = {data: form_data};
751
752         _(record).each(function (value, key) {
753             form_data[key] = {value: value};
754         });
755
756         return form_record;
757     },
758     /**
759      * Reloads the record at index ``row_index`` in the list's rows.
760      *
761      * By default, simply re-renders the record. If the ``fetch`` parameter is
762      * provided and ``true``, will first fetch the record anew.
763      *
764      * @param {Number} record_index index of the record to reload
765      * @param {Boolean} fetch fetches the record from remote before reloading it
766      */
767     reload_record: function (record_index, fetch) {
768         var self = this;
769         var read_p = null;
770         if (fetch) {
771             // save index to restore it later, if already set
772             var old_index = this.dataset.index;
773             this.dataset.index = record_index;
774             read_p = this.dataset.read_index(
775                 _.filter(_.pluck(this.columns, 'name'), _.identity),
776                 function (record) {
777                     var form_record = self.transform_record(record);
778                     self.rows.splice(record_index, 1, form_record);
779                     self.dataset.index = old_index;
780                 }
781             )
782         }
783
784         return $.when(read_p).then(function () {
785             self.$current.children().eq(record_index)
786                 .replaceWith(self.render_record(record_index)); });
787     },
788     /**
789      * Renders a list record to HTML
790      *
791      * @param {Number} record_index index of the record to render in ``this.rows``
792      * @returns {String} QWeb rendering of the selected record
793      */
794     render_record: function (record_index) {
795         return QWeb.render('ListView.row', {
796             columns: this.columns,
797             options: this.options,
798             row: this.rows[record_index],
799             row_parity: (record_index % 2 === 0) ? 'even' : 'odd',
800             row_index: record_index,
801             render_cell: openerp.base.format_cell
802         });
803     },
804     /**
805      * Stops displaying the records matching the provided ids.
806      *
807      * @param {Array} ids identifiers of the records to remove
808      */
809     drop_records: function (ids) {
810         var self = this;
811         _(this.rows).chain()
812               .map(function (record, index) {
813                 return {index: index, id: record.data.id.value};
814             }).filter(function (record) {
815                 return _(ids).contains(record.id);
816             }).reverse()
817               .each(function (record) {
818                 self.$current.find('tr:eq(' + record.index + ')').remove();
819                 self.rows.splice(record.index, 1);
820             })
821     }
822     // drag and drop
823 });
824 openerp.base.ListView.Groups = openerp.base.Class.extend( /** @lends openerp.base.ListView.Groups# */{
825     passtrough_events: 'action deleted row_link',
826     /**
827      * Grouped display for the ListView. Handles basic DOM events and interacts
828      * with the :js:class:`~openerp.base.DataGroup` bound to it.
829      *
830      * Provides events similar to those of
831      * :js:class:`~openerp.base.ListView.List`
832      */
833     init: function (view) {
834         this.view = view;
835         this.options = view.options;
836         this.columns = view.columns;
837         this.datagroup = null;
838
839         this.$row = null;
840         this.children = {};
841
842         this.page = 0;
843     },
844     make_fragment: function () {
845         return document.createDocumentFragment();
846     },
847     /**
848      * Returns a DOM node after which a new tbody can be inserted, so that it
849      * follows the provided row.
850      *
851      * Necessary to insert the result of a new group or list view within an
852      * existing groups render, without losing track of the groups's own
853      * elements
854      *
855      * @param {HTMLTableRowElement} row the row after which the caller wants to insert a body
856      * @returns {HTMLTableSectionElement} element after which a tbody can be inserted
857      */
858     point_insertion: function (row) {
859         var $row = $(row);
860         var red_letter_tboday = $row.closest('tbody')[0];
861
862         var $next_siblings = $row.nextAll();
863         if ($next_siblings.length) {
864             var $root_kanal = $('<tbody>').insertAfter(red_letter_tboday);
865
866             $root_kanal.append($next_siblings);
867             this.elements.splice(
868                 _.indexOf(this.elements, red_letter_tboday),
869                 0,
870                 $root_kanal[0]);
871         }
872         return red_letter_tboday;
873     },
874     make_paginator: function () {
875         var self = this;
876         var $prev = $('<button type="button" data-pager-action="previous">&lt;</button>')
877             .click(function (e) {
878                 e.stopPropagation();
879                 self.page -= 1;
880
881                 self.$row.closest('tbody').next()
882                     .replaceWith(self.render());
883             });
884         var $next = $('<button type="button" data-pager-action="next">&gt;</button>')
885             .click(function (e) {
886                 e.stopPropagation();
887                 self.page += 1;
888
889                 self.$row.closest('tbody').next()
890                     .replaceWith(self.render());
891             });
892         this.$row.children().last()
893             .append($prev)
894             .append('<span class="oe-pager-state"></span>')
895             .append($next);
896     },
897     open: function (point_insertion) {
898         this.render().insertAfter(point_insertion);
899         this.make_paginator();
900     },
901     close: function () {
902         this.$row.children().last().empty();
903         this.apoptosis();
904     },
905     /**
906      * Prefixes ``$node`` with floated spaces in order to indent it relative
907      * to its own left margin/baseline
908      *
909      * @param {jQuery} $node jQuery object to indent
910      * @param {Number} level current nesting level, >= 1
911      * @returns {jQuery} the indentation node created
912      */
913     indent: function ($node, level) {
914         return $('<span>')
915                 .css({'float': 'left', 'white-space': 'pre'})
916                 .text(new Array(level).join('   '))
917                 .prependTo($node);
918     },
919     render_groups: function (datagroups) {
920         var self = this;
921         var placeholder = this.make_fragment();
922         _(datagroups).each(function (group) {
923             if (self.children[group.value]) {
924                 self.children[group.value].apoptosis();
925                 delete self.children[group.value];
926             }
927             var child = self.children[group.value] = new openerp.base.ListView.Groups(self.view, {
928                 options: self.options,
929                 columns: self.columns
930             });
931             self.bind_child_events(child);
932             child.datagroup = group;
933
934             var $row = child.$row = $('<tr>');
935             if (group.openable) {
936                 $row.click(function (e) {
937                     if (!$row.data('open')) {
938                         $row.data('open', true)
939                             .find('span.ui-icon')
940                                 .removeClass('ui-icon-triangle-1-e')
941                                 .addClass('ui-icon-triangle-1-s');
942                         child.open(self.point_insertion(e.currentTarget));
943                     } else {
944                         $row.removeData('open')
945                             .find('span.ui-icon')
946                                 .removeClass('ui-icon-triangle-1-s')
947                                 .addClass('ui-icon-triangle-1-e');
948                         child.close();
949                     }
950                 });
951             }
952             placeholder.appendChild($row[0]);
953
954             var $group_column = $('<th class="oe-group-name">').appendTo($row);
955             // Don't fill this if group_by_no_leaf but no group_by
956             if (group.grouped_on) {
957                 var row_data = {};
958                 row_data[group.grouped_on] = group;
959                 var group_column = _(self.columns).detect(function (column) {
960                     return column.id === group.grouped_on; });
961                 $group_column.html(openerp.base.format_cell(
962                     row_data, group_column, "Undefined"
963                 ));
964                 if (group.openable) {
965                     // Make openable if not terminal group & group_by_no_leaf
966                     $group_column
967                         .prepend('<span class="ui-icon ui-icon-triangle-1-e" style="float: left;">');
968                 }
969             }
970             self.indent($group_column, group.level);
971             // count column
972             $('<td>').text(group.length).appendTo($row);
973
974             if (self.options.selectable) {
975                 $row.append('<td>');
976             }
977             _(self.columns).chain()
978                 .filter(function (column) {return !column.invisible;})
979                 .each(function (column) {
980                     if (column.meta) {
981                         // do not do anything
982                     } else if (column.id in group.aggregates) {
983                         var value = group.aggregates[column.id];
984                         var format;
985                         if (column.type === 'integer') {
986                             format = "%.0f";
987                         } else if (column.type === 'float') {
988                             format = "%.2f";
989                         }
990                         $('<td>')
991                             .text(_.sprintf(format, value))
992                             .appendTo($row);
993                     } else {
994                         $row.append('<td>');
995                     }
996                 });
997             if (self.options.deletable) {
998                 $row.append('<td class="oe-group-pagination">');
999             }
1000         });
1001         return placeholder;
1002     },
1003     bind_child_events: function (child) {
1004         var $this = $(this),
1005              self = this;
1006         $(child).bind('selected', function (e) {
1007             // can have selections spanning multiple links
1008             var selection = self.get_selection();
1009             $this.trigger(e, [selection.ids, selection.records]);
1010         }).bind(this.passtrough_events, function (e) {
1011             // additional positional parameters are provided to trigger as an
1012             // Array, following the event type or event object, but are
1013             // provided to the .bind event handler as *args.
1014             // Convert our *args back into an Array in order to trigger them
1015             // on the group itself, so it can ultimately be forwarded wherever
1016             // it's supposed to go.
1017             var args = Array.prototype.slice.call(arguments, 1);
1018             $this.trigger.call($this, e, args);
1019         });
1020     },
1021     render_dataset: function (dataset) {
1022         var rows = [],
1023             self = this,
1024             list = new openerp.base.ListView.List(this, {
1025                 options: this.options,
1026                 columns: this.columns,
1027                 dataset: dataset,
1028                 rows: rows
1029             });
1030         this.bind_child_events(list);
1031
1032         var view = this.view,
1033            limit = view.limit(),
1034                d = new $.Deferred(),
1035             page = this.datagroup.openable ? this.page : view.page;
1036
1037         dataset.read_slice({
1038                 fields: _.filter(_.pluck(_.select(this.columns, function(x) {return x.tag == "field";}), 'name'), _.identity),
1039                 offset: page * limit,
1040                 limit: limit
1041             }, function (records) {
1042                 if (!self.datagroup.openable) {
1043                     view.configure_pager(dataset);
1044                 } else {
1045                     var pages = Math.ceil(dataset.ids.length / limit);
1046                     self.$row
1047                         .find('.oe-pager-state')
1048                             .text(_.sprintf('%d/%d', page + 1, pages))
1049                         .end()
1050                         .find('button[data-pager-action=previous]')
1051                             .attr('disabled', page === 0)
1052                         .end()
1053                         .find('button[data-pager-action=next]')
1054                             .attr('disabled', page === pages - 1);
1055                 }
1056
1057                 var form_records = _(records).map(
1058                     $.proxy(list, 'transform_record'));
1059
1060                 rows.splice(0, rows.length);
1061                 rows.push.apply(rows, form_records);
1062                 list.render();
1063                 d.resolve(list);
1064             });
1065         return d.promise();
1066     },
1067     setup_resequence_rows: function (list, dataset) {
1068         // drag and drop enabled if list is not sorted and there is a
1069         // "sequence" column in the view.
1070         if ((dataset.sort && dataset.sort())
1071             || !_(this.columns).any(function (column) {
1072                     return column.name === 'sequence'; })) {
1073             return;
1074         }
1075         // ondrop, move relevant record & fix sequences
1076         list.$current.sortable({
1077             stop: function (event, ui) {
1078                 var from = ui.item.data('index'),
1079                       to = ui.item.prev().data('index') || 0;
1080                 if (from === to) { return; }
1081                 list.rows.splice(to, 0, list.rows.splice(from, 1)[0]);
1082
1083                 ui.item.parent().children().each(function (i, e) {
1084                     // reset record-index accelerators on rows and even/odd
1085                     var even = i%2 === 0;
1086                     $(e).data('index', i)
1087                         .toggleClass('even', even)
1088                         .toggleClass('odd', !even);
1089                 });
1090
1091                 // resequencing time!
1092                 var data, index = to,
1093                     // if drag to 1st row (to = 0), start sequencing from 0
1094                     // (exclusive lower bound)
1095                     seq = to ? list.rows[to - 1].data.sequence.value : 0;
1096                 while (++seq, list.rows[index]) {
1097                     data = list.rows[index].data;
1098                     data.sequence.value = seq;
1099                     // write are independent from one another, so we can just
1100                     // launch them all at the same time and we don't really
1101                     // give a fig about when they're done
1102                     dataset.write(data.id.value, {sequence: seq});
1103                     list.reload_record(index++);
1104                 }
1105             }
1106         });
1107     },
1108     render: function (post_render) {
1109         var self = this;
1110         var $element = $('<tbody>');
1111         this.elements = [$element[0]];
1112
1113         this.datagroup.list(
1114             _(this.view.visible_columns).chain()
1115                 .filter(function (column) { return column.tag === 'field' })
1116                 .pluck('name').value(),
1117             function (groups) {
1118                 $element[0].appendChild(
1119                     self.render_groups(groups));
1120                 if (post_render) { post_render(); }
1121             }, function (dataset) {
1122                 self.render_dataset(dataset).then(function (list) {
1123                     self.children[null] = list;
1124                     self.elements =
1125                         [list.$current.replaceAll($element)[0]];
1126                     self.setup_resequence_rows(list, dataset);
1127                     if (post_render) { post_render(); }
1128                 });
1129             });
1130         return $element;
1131     },
1132     /**
1133      * Returns the ids of all selected records for this group, and the records
1134      * themselves
1135      */
1136     get_selection: function () {
1137         var ids = [], records = [];
1138
1139         _(this.children)
1140             .each(function (child) {
1141                 var selection = child.get_selection();
1142                 ids.push.apply(ids, selection.ids);
1143                 records.push.apply(records, selection.records);
1144             });
1145
1146         return {ids: ids, records: records};
1147     },
1148     apoptosis: function () {
1149         _(this.children).each(function (child) {
1150             child.apoptosis();
1151         });
1152         this.children = {};
1153         $(this.elements).remove();
1154         return this;
1155     },
1156     get_records: function () {
1157         if (_(this.children).isEmpty()) {
1158             return {
1159                 count: this.datagroup.length,
1160                 values: this.datagroup.aggregates
1161             }
1162         }
1163         return _(this.children).chain()
1164             .map(function (child) {
1165                 return child.get_records();
1166             }).flatten().value();
1167     },
1168     /**
1169      * Stops displaying the records with the linked ids, assumes these records
1170      * were deleted from the DB.
1171      *
1172      * This is the up-signal from the `deleted` event on groups and lists.
1173      *
1174      * @param {Array} ids list of identifier of the records to remove.
1175      */
1176     drop_records: function (ids) {
1177         _.each(this.children, function (child) {
1178             child.drop_records(ids);
1179         });
1180     }
1181 });
1182 };
1183
1184 // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax:
1185