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# */ {
5 // records can be selected one by one
7 // list rows can be deleted
9 // whether the column headers should be displayed
11 // display addition button, with that label
13 // whether the list view can be sorted, note that once a view has been
14 // sorted it can not be reordered anymore
16 // whether the view rows can be reordered (via vertical drag & drop)
20 * Core class for list-type displays.
22 * As a view, needs a number of view-related parameters to be correctly
23 * instantiated, provides options and overridable methods for behavioral
26 * See constructor parameters and method documentations for information on
27 * the default behaviors and possible options for the list view.
31 * @param session An OpenERP session object
32 * @param element_id the id of the DOM elements this view should link itself to
33 * @param {openerp.base.DataSet} dataset the dataset the view should work with
34 * @param {String} view_id the listview's identifier, if any
35 * @param {Object} options A set of options used to configure the view
36 * @param {Boolean} [options.selectable=true] determines whether view rows are selectable (e.g. via a checkbox)
37 * @param {Boolean} [options.header=true] should the list's header be displayed
38 * @param {Boolean} [options.deletable=true] are the list rows deletable
39 * @param {null|String} [options.addable="New"] should the new-record button be displayed, and what should its label be. Use ``null`` to hide the button.
40 * @param {Boolean} [options.sortable=true] is it possible to sort the table by clicking on column headers
41 * @param {Boolean} [options.reorderable=true] is it possible to reorder list rows
43 * @borrows openerp.base.ActionExecutor#execute_action as #execute_action
45 init: function(view_manager, session, element_id, dataset, view_id, options) {
46 this._super(session, element_id);
47 this.view_manager = view_manager || new openerp.base.NullViewManager();
48 this.dataset = dataset;
49 this.model = dataset.model;
50 this.view_id = view_id;
54 this.options = _.extend({}, this.defaults, options || {});
55 this.flags = this.view_manager.flags || {};
57 this.set_groups(new openerp.base.ListView.Groups(this));
59 if (this.dataset instanceof openerp.base.DataSetStatic) {
60 this.groups.datagroup = new openerp.base.StaticDataGroup(this.dataset);
64 * Set a custom Group construct as the root of the List View.
66 * @param {openerp.base.ListView.Groups} groups
68 set_groups: function (groups) {
71 $(this.groups).unbind("selected deleted action row_link");
77 'selected': function (e, ids, records) {
78 self.do_select(ids, records);
80 'deleted': function (e, ids) {
83 'action': function (e, action_name, id, callback) {
84 self.do_action(action_name, id, callback);
86 'row_link': function (e, id, dataset) {
87 self.do_activate_record(dataset.index, id, dataset);
92 * View startup method, the default behavior is to set the ``oe-listview``
93 * class on its root element and to perform an RPC load call.
95 * @returns {$.Deferred} loading promise
98 this.$element.addClass('oe-listview');
99 return this.reload_view();
102 * Called after loading the list view's description, sets up such things
103 * as the view table's columns, renders the table itself and hooks up the
104 * various table-level and row-level DOM events (action buttons, deletion
105 * buttons, selection of records, [New] button, selection of a given
108 * Sets up the following:
110 * * Processes arch and fields to generate a complete field descriptor for each field
111 * * Create the table itself and allocate visible columns
112 * * Hook in the top-level (header) [New|Add] and [Delete] button
113 * * Sets up showing/hiding the top-level [Delete] button based on records being selected or not
114 * * Sets up event handlers for action buttons and per-row deletion button
115 * * Hooks global callback for clicking on a row
116 * * Sets up its sidebar, if any
118 * @param {Object} data wrapped fields_view_get result
119 * @param {Object} data.fields_view fields_view_get result (processed)
120 * @param {Object} data.fields_view.fields mapping of fields for the current model
121 * @param {Object} data.fields_view.arch current list view descriptor
122 * @param {Boolean} grouped Is the list view grouped
124 on_loaded: function(data, grouped) {
126 this.fields_view = data.fields_view;
127 //this.log(this.fields_view);
128 this.name = "" + this.fields_view.arch.attrs.string;
130 this.setup_columns(this.fields_view.fields, grouped);
132 if (!this.fields_view.sorted) { this.fields_view.sorted = {}; }
134 this.$element.html(QWeb.render("ListView", this));
137 this.$element.find('#oe-list-add')
138 .click(this.do_add_record)
139 .attr('disabled', grouped && this.options.editable);
140 this.$element.find('#oe-list-delete')
141 .attr('disabled', true)
142 .click(this.do_delete_selected);
143 this.$element.find('thead').delegate('th[data-id]', 'click', function (e) {
146 self.dataset.sort($(this).data('id'));
148 // TODO: should only reload content (and set the right column to a sorted display state)
152 this.view_manager.sidebar.set_toolbar(data.fields_view.toolbar);
155 * Sets up the listview's columns: merges view and fields data, move
156 * grouped-by columns to the front of the columns list and make them all
159 * @param {Object} fields fields_view_get's fields section
160 * @param {Boolean} [grouped] Should the grouping columns (group and count) be displayed
162 setup_columns: function (fields, grouped) {
163 var domain_computer = openerp.base.form.compute_domain;
165 var noop = function () { return {}; };
166 var field_to_column = function (field) {
167 var name = field.attrs.name;
168 var column = _.extend({id: name, tag: field.tag},
169 field.attrs, fields[name]);
172 var attrs = JSON.parse(column.attrs);
173 column.attrs_for = function (fields) {
175 for (var attr in attrs) {
176 result[attr] = domain_computer(attrs[attr], fields);
181 column.attrs_for = noop;
186 this.columns.splice(0, this.columns.length);
187 this.columns.push.apply(
189 _(this.fields_view.arch.children).map(field_to_column));
191 this.columns.unshift({
192 id: '_group', tag: '', string: "Group", meta: true,
193 attrs_for: function () { return {}; }
195 id: '_count', tag: '', string: '#', meta: true,
196 attrs_for: function () { return {}; }
200 this.visible_columns = _.filter(this.columns, function (column) {
201 return column.invisible !== '1';
204 this.aggregate_columns = _(this.visible_columns)
205 .map(function (column) {
206 if (column.type !== 'integer' && column.type !== 'float') {
209 var aggregation_func = column['group_operator'] || 'sum';
211 if (!column[aggregation_func]) {
218 'function': aggregation_func,
219 label: column[aggregation_func]
224 * Used to handle a click on a table row, if no other handler caught the
227 * The default implementation asks the list view's view manager to switch
228 * to a different view (by calling
229 * :js:func:`~openerp.base.ViewManager.on_mode_switch`), using the
230 * provided record index (within the current list view's dataset).
232 * If the index is null, ``switch_to_record`` asks for the creation of a
235 * @param {Number|null} index the record index (in the current dataset) to switch to
236 * @param {String} [view="form"] the view type to switch to
238 select_record:function (index, view) {
239 view = view || 'form';
240 this.dataset.index = index;
241 _.delay(_.bind(function () {
242 if(this.view_manager) {
243 this.view_manager.on_mode_switch(view);
247 do_show: function () {
248 this.$element.show();
250 this.$element.find('table').append(
251 this.groups.apoptosis().render());
254 this.view_manager.sidebar.do_refresh(true);
256 do_hide: function () {
257 this.$element.hide();
261 * Reloads the list view based on the current settings (dataset & al)
263 * @param {Boolean} [grouped] Should the list be displayed grouped
265 reload_view: function (grouped) {
267 this.dataset.offset = 0;
268 this.dataset.limit = false;
269 var callback = function (field_view_get) {
270 self.on_loaded(field_view_get, grouped);
272 if (this.embedded_view) {
273 return $.Deferred().then(callback).resolve({fields_view: this.embedded_view});
275 return this.rpc('/base/listview/load', {
277 view_id: this.view_id,
278 context: this.dataset.context,
279 toolbar: !!this.flags.sidebar
284 * re-renders the content of the list view
286 reload_content: function () {
287 this.$element.find('table').append(
288 this.groups.apoptosis().render(
289 $.proxy(this, 'compute_aggregates')));
292 * Event handler for a search, asks for the computation/folding of domains
293 * and contexts (and group-by), then reloads the view's content.
295 * @param {Array} domains a sequence of literal and non-literal domains
296 * @param {Array} contexts a sequence of literal and non-literal contexts
297 * @param {Array} groupbys a sequence of literal and non-literal group-by contexts
298 * @returns {$.Deferred} fold request evaluation promise
300 do_search: function (domains, contexts, groupbys) {
301 return this.rpc('/base/session/eval_domain_and_context', {
304 group_by_seq: groupbys
305 }, $.proxy(this, 'do_actual_search'));
308 * Handler for the result of eval_domain_and_context, actually perform the
311 * @param {Object} results results of evaluating domain and process for a search
313 do_actual_search: function (results) {
314 this.dataset.context = results.context;
315 this.dataset.domain = results.domain;
316 this.groups.datagroup = new openerp.base.DataGroup(
317 this.session, this.model,
318 results.domain, results.context,
321 if (_.isEmpty(results.group_by) && !results.context['group_by_no_leaf']) {
322 results.group_by = null;
325 this.reload_view(!!results.group_by).then(
326 $.proxy(this, 'reload_content'));
329 * Handles the signal to delete a line from the DOM
331 * @param {Array} ids the id of the object to delete
333 do_delete: function (ids) {
338 return $.when(this.dataset.unlink(ids)).then(function () {
340 .map(function (row, index) {
343 id: row.data.id.value
345 .filter(function (record) {
346 return _.contains(ids, record.id);
348 .sort(function (a, b) {
349 // sort in reverse index order, so we delete from the end
350 // and don't blow up the following indexes (leading to
351 // removing the wrong records from the visible list)
352 return b.index - a.index;
354 .each(function (record) {
355 self.rows.splice(record.index, 1);
357 // TODO only refresh modified rows
361 * Handles the signal indicating that a new record has been selected
363 * @param {Array} ids selected record ids
364 * @param {Array} records selected record values
366 do_select: function (ids, records) {
367 this.$element.find('#oe-list-delete')
368 .attr('disabled', !ids.length);
370 if (!records.length) {
371 this.compute_aggregates();
374 this.compute_aggregates(records);
377 * Handles action button signals on a record
379 * @param {String} name action name
380 * @param {Object} id id of the record the action should be called on
381 * @param {Function} callback should be called after the action is executed, if non-null
383 do_action: function (name, id, callback) {
384 var action = _.detect(this.columns, function (field) {
385 return field.name === name;
387 if (!action) { return; }
389 action, this.dataset, this.session.action_manager,
397 * Handles the activation of a record (clicking on it)
399 * @param {Number} index index of the record in the dataset
400 * @param {Object} id identifier of the activated record
401 * @param {openerp.base.DataSet} dataset dataset in which the record is available (may not be the listview's dataset in case of nested groups)
403 do_activate_record: function (index, id, dataset) {
405 _.extend(this.dataset, {
406 domain: dataset.domain,
407 context: dataset.context
408 }).read_slice([], 0, false, function () {
409 self.select_record(index);
413 * Handles signal for the addition of a new record (can be a creation,
414 * can be the addition from a remote source, ...)
416 * The default implementation is to switch to a new record on the form view
418 do_add_record: function () {
419 this.select_record(null);
422 * Handles deletion of all selected lines
424 do_delete_selected: function () {
425 this.do_delete(this.groups.get_selection().ids);
428 * Computes the aggregates for the current list view, either on the
429 * records provided or on the records of the internal
430 * :js:class:`~openerp.base.ListView.Group`, by calling
431 * :js:func:`~openerp.base.ListView.group.get_records`.
433 * Then displays the aggregates in the table through
434 * :js:method:`~openerp.base.ListView.display_aggregates`.
436 * @param {Array} [records]
438 compute_aggregates: function (records) {
439 var columns = _(this.aggregate_columns).filter(function (column) {
440 return column['function']; });
442 if (_.isEmpty(columns)) { return; }
444 if (_.isEmpty(records)) {
445 records = this.groups.get_records();
448 var count = 0, sums = {};
449 _(columns).each(function (column) { sums[column.field] = 0; });
450 _(records).each(function (record) {
451 count += 1; //(record.count || 1);
452 _(columns).each(function (column) {
453 sums[column.field] += record[column.field];
458 _(columns).each(function (column) {
459 var field = column.field;
460 switch (column['function']) {
462 aggregates[field] = sums[field];
465 aggregates[field] = sums[field] / count;
470 this.display_aggregates(aggregates);
472 display_aggregates: function (aggregation) {
473 var $footer_cells = this.$element.find('.oe-list-footer');
474 _(this.aggregate_columns).each(function (column) {
475 if (!column['function']) {
478 var pattern = (column.type == 'integer') ? '%d' : '%.2f';
479 $footer_cells.filter(_.sprintf('[data-field=%s]', column.field))
480 .text(_.sprintf(pattern, aggregation[column.field]));
483 // TODO: implement reorder (drag and drop rows)
485 openerp.base.ListView.List = Class.extend( /** @lends openerp.base.ListView.List# */{
487 * List display for the ListView, handles basic DOM events and transforms
488 * them in the relevant higher-level events, to which the list view (or
489 * other consumers) can subscribe.
491 * Events on this object are registered via jQuery.
496 * Triggered when a row is selected (using check boxes), provides an
497 * array of ids of all the selected records.
499 * Triggered when deletion buttons are hit, provide an array of ids of
500 * all the records being marked for suppression.
502 * Triggered when an action button is clicked, provides two parameters:
504 * * The name of the action to execute (as a string)
505 * * The id of the record to execute the action on
507 * Triggered when a row of the table is clicked, provides the index (in
508 * the rows array) and id of the selected record to the handle function.
511 * @param {Object} opts display options, identical to those of :js:class:`openerp.base.ListView`
513 init: function (group, opts) {
517 this.options = opts.options;
518 this.columns = opts.columns;
519 this.dataset = opts.dataset;
520 this.rows = opts.rows;
522 this.$_element = $('<tbody class="ui-widget-content">')
523 .appendTo(document.body)
524 .delegate('th.oe-record-selector', 'click', function (e) {
526 var selection = self.get_selection();
528 'selected', [selection.ids, selection.records]);
530 .delegate('td.oe-record-delete button', 'click', function (e) {
532 var $row = $(e.target).closest('tr');
533 $(self).trigger('deleted', [[self.row_id($row)]]);
535 .delegate('td.oe-field-cell button', 'click', function (e) {
537 var $target = $(e.currentTarget),
538 field = $target.closest('td').data('field'),
539 $row = $target.closest('tr'),
540 record_id = self.row_id($row),
541 index = self.row_position($row);
543 $(self).trigger('action', [field, record_id, function () {
544 self.reload_record(index, true);
547 .delegate('tr', 'click', function (e) {
549 self.dataset.index = self.row_position(e.currentTarget);
553 row_clicked: function () {
556 [this.rows[this.dataset.index].data.id.value,
559 render: function () {
561 this.$current.remove();
563 this.$current = this.$_element.clone(true);
564 this.$current.empty().append($(QWeb.render('ListView.rows', this)));
566 get_fields_view: function () {
568 var view = $.extend(true, {}, this.group.view.fields_view);
569 _(view.arch.children).each(function (widget) {
570 widget.attrs.nolabel = true;
571 if (widget.tag === 'button') {
572 delete widget.attrs.string;
575 view.arch.attrs.col = 2 * view.arch.children.length;
579 * Gets the ids of all currently selected records, if any
580 * @returns {Object} object with the keys ``ids`` and ``records``, holding respectively the ids of all selected records and the records themselves.
582 get_selection: function () {
583 if (!this.options.selectable) {
586 var rows = this.rows;
587 var result = {ids: [], records: []};
588 this.$current.find('th.oe-record-selector input:checked')
589 .closest('tr').each(function () {
591 _(rows[$(this).data('index')].data).each(function (obj, key) {
592 record[key] = obj.value;
594 result.ids.push(record.id);
595 result.records.push(record);
600 * Returns the index of the row in the list of rows.
602 * @param {Object} row the selected row
603 * @returns {Number} the position of the row in this.rows
605 row_position: function (row) {
606 return $(row).data('index');
609 * Returns the identifier of the object displayed in the provided table
612 * @param {Object} row the selected table row
613 * @returns {Number|String} the identifier of the row's object
615 row_id: function (row) {
616 return this.rows[this.row_position(row)].data.id.value;
619 * Death signal, cleans up list
621 apoptosis: function () {
622 if (!this.$current) { return; }
623 this.$current.remove();
624 this.$current = null;
625 this.$_element.remove();
627 get_records: function () {
628 return _(this.rows).map(function (row) {
630 _(row.data).each(function (obj, key) {
631 record[key] = obj.value;
637 * Transforms a record from what is returned by a dataset read (a simple
638 * mapping of ``$fieldname: $value``) to the format expected by list rows
647 * This format allows for the insertion of a bunch of metadata (names,
650 * @param {Object} record original record, in dataset format
651 * @returns {Object} record displayable in a form or list view
653 transform_record: function (record) {
654 // TODO: colors handling
656 form_record = {data: form_data};
658 _(record).each(function (value, key) {
659 form_data[key] = {value: value};
665 * Reloads the record at index ``row_index`` in the list's rows.
667 * By default, simply re-renders the record. If the ``fetch`` parameter is
668 * provided and ``true``, will first fetch the record anew.
670 * @param {Number} record_index index of the record to reload
671 * @param {Boolean} fetch fetches the record from remote before reloading it
673 reload_record: function (record_index, fetch) {
677 // save index to restore it later, if already set
678 var old_index = this.dataset.index;
679 this.dataset.index = record_index;
680 read_p = this.dataset.read_index(
681 _.filter(_.pluck(this.columns, 'name'), _.identity),
683 var form_record = self.transform_record(record);
684 self.rows.splice(record_index, 1, form_record);
685 self.dataset.index = old_index;
690 return $.when(read_p).then(function () {
691 self.$current.children().eq(record_index)
692 .replaceWith(self.render_record(record_index)); })
695 * Renders a list record to HTML
697 * @param {Number} record_index index of the record to render in ``this.rows``
698 * @returns {String} QWeb rendering of the selected record
700 render_record: function (record_index) {
701 return QWeb.render('ListView.row', {
702 columns: this.columns,
703 options: this.options,
704 row: this.rows[record_index],
705 row_parity: (record_index % 2 === 0) ? 'even' : 'odd',
706 row_index: record_index
711 openerp.base.ListView.Groups = Class.extend( /** @lends openerp.base.ListView.Groups# */{
712 passtrough_events: 'action deleted row_link',
714 * Grouped display for the ListView. Handles basic DOM events and interacts
715 * with the :js:class:`~openerp.base.DataGroup` bound to it.
717 * Provides events similar to those of
718 * :js:class:`~openerp.base.ListView.List`
720 init: function (view) {
722 this.options = view.options;
723 this.columns = view.columns;
724 this.datagroup = null;
729 pad: function ($row) {
730 if (this.options.selectable) {
734 make_fragment: function () {
735 return document.createDocumentFragment();
738 * Returns a DOM node after which a new tbody can be inserted, so that it
739 * follows the provided row.
741 * Necessary to insert the result of a new group or list view within an
742 * existing groups render, without losing track of the groups's own
745 * @param {HTMLTableRowElement} row the row after which the caller wants to insert a body
746 * @returns {HTMLTableSectionElement} element after which a tbody can be inserted
748 point_insertion: function (row) {
750 var red_letter_tboday = $row.closest('tbody')[0];
752 var $next_siblings = $row.nextAll();
753 if ($next_siblings.length) {
754 var $root_kanal = $('<tbody>').insertAfter(red_letter_tboday);
756 $root_kanal.append($next_siblings);
757 this.elements.splice(
758 _.indexOf(this.elements, red_letter_tboday),
762 return red_letter_tboday;
764 open: function (point_insertion) {
765 this.render().insertAfter(point_insertion);
771 * Prefixes ``$node`` with floated spaces in order to indent it relative
772 * to its own left margin/baseline
774 * @param {jQuery} $node jQuery object to indent
775 * @param {Number} level current nesting level, >= 1
776 * @returns {jQuery} the indentation node created
778 indent: function ($node, level) {
780 .css({'float': 'left', 'white-space': 'pre'})
781 .text(new Array(level).join(' '))
784 render_groups: function (datagroups) {
786 var placeholder = this.make_fragment();
787 _(datagroups).each(function (group) {
788 if (self.children[group.value]) {
789 self.children[group.value].apoptosis();
790 delete self.children[group.value];
792 var child = self.children[group.value] = new openerp.base.ListView.Groups(self.view, {
793 options: self.options,
794 columns: self.columns
796 self.bind_child_events(child);
797 child.datagroup = group;
799 var $row = $('<tr>');
800 if (group.openable) {
801 $row.click(function (e) {
802 if (!$row.data('open')) {
803 $row.data('open', true)
804 .find('span.ui-icon')
805 .removeClass('ui-icon-triangle-1-e')
806 .addClass('ui-icon-triangle-1-s');
807 child.open(self.point_insertion(e.currentTarget));
809 $row.removeData('open')
810 .find('span.ui-icon')
811 .removeClass('ui-icon-triangle-1-s')
812 .addClass('ui-icon-triangle-1-e');
817 placeholder.appendChild($row[0]);
819 var $group_column = $('<th>').appendTo($row);
820 if (group.grouped_on) {
821 // Don't fill this if group_by_no_leaf but no group_by
823 .text((group.value instanceof Array ? group.value[1] : group.value));
824 if (group.openable) {
825 // Make openable if not terminal group & group_by_no_leaf
827 .prepend('<span class="ui-icon ui-icon-triangle-1-e" style="float: left;">');
830 self.indent($group_column, group.level);
832 $('<td>').text(group.length).appendTo($row);
835 _(self.columns).chain()
836 .filter(function (column) {return !column.invisible;})
837 .each(function (column) {
839 // do not do anything
840 } else if (column.id in group.aggregates) {
841 var value = group.aggregates[column.id];
843 if (column.type === 'integer') {
845 } else if (column.type === 'float') {
849 .text(_.sprintf(format, value))
858 bind_child_events: function (child) {
861 $(child).bind('selected', function (e) {
862 // can have selections spanning multiple links
863 var selection = self.get_selection();
864 $this.trigger(e, [selection.ids, selection.records]);
865 }).bind(this.passtrough_events, function (e) {
866 // additional positional parameters are provided to trigger as an
867 // Array, following the event type or event object, but are
868 // provided to the .bind event handler as *args.
869 // Convert our *args back into an Array in order to trigger them
870 // on the group itself, so it can ultimately be forwarded wherever
871 // it's supposed to go.
872 var args = Array.prototype.slice.call(arguments, 1);
873 $this.trigger.call($this, e, args);
876 render_dataset: function (dataset) {
878 list = new openerp.base.ListView.List(this, {
879 options: this.options,
880 columns: this.columns,
884 this.bind_child_events(list);
886 var d = new $.Deferred();
888 _.filter(_.pluck(this.columns, 'name'), _.identity),
891 var form_records = _(records).map(
892 $.proxy(list, 'transform_record'));
894 rows.splice(0, rows.length);
895 rows.push.apply(rows, form_records);
901 render: function (post_render) {
903 var $element = $('<tbody>');
904 this.elements = [$element[0]];
905 this.datagroup.list(function (groups) {
906 $element[0].appendChild(
907 self.render_groups(groups));
908 if (post_render) { post_render(); }
909 }, function (dataset) {
910 self.render_dataset(dataset).then(function (list) {
911 self.children[null] = list;
913 [list.$current.replaceAll($element)[0]];
914 if (post_render) { post_render(); }
920 * Returns the ids of all selected records for this group, and the records
923 get_selection: function () {
924 var ids = [], records = [];
927 .each(function (child) {
928 var selection = child.get_selection();
929 ids.push.apply(ids, selection.ids);
930 records.push.apply(records, selection.records);
933 return {ids: ids, records: records};
935 apoptosis: function () {
936 _(this.children).each(function (child) {
940 $(this.elements).remove();
943 get_records: function () {
944 return _(this.children).chain()
945 .map(function (child) {
946 return child.get_records();
947 }).flatten().value();
952 // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax: