1 openerp.web.list = function (openerp) {
2 var _t = openerp.web._t,
4 var QWeb = openerp.web.qweb;
5 openerp.web.views.add('list', 'openerp.web.ListView');
6 openerp.web.ListView = openerp.web.View.extend( /** @lends openerp.web.ListView# */ {
8 display_name: _lt('List'),
10 // records can be selected one by one
12 // list rows can be deleted
14 // whether the column headers should be displayed
16 // display addition button, with that label
17 'addable': _lt("Create"),
18 // whether the list view can be sorted, note that once a view has been
19 // sorted it can not be reordered anymore
21 // whether the view rows can be reordered (via vertical drag & drop)
23 // display an edit icon linking to form view
27 * Core class for list-type displays.
29 * As a view, needs a number of view-related parameters to be correctly
30 * instantiated, provides options and overridable methods for behavioral
33 * See constructor parameters and method documentations for information on
34 * the default behaviors and possible options for the list view.
36 * @constructs openerp.web.ListView
37 * @extends openerp.web.View
39 * @param parent parent object
40 * @param {openerp.web.DataSet} dataset the dataset the view should work with
41 * @param {String} view_id the listview's identifier, if any
42 * @param {Object} options A set of options used to configure the view
43 * @param {Boolean} [options.selectable=true] determines whether view rows are selectable (e.g. via a checkbox)
44 * @param {Boolean} [options.header=true] should the list's header be displayed
45 * @param {Boolean} [options.deletable=true] are the list rows deletable
46 * @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.
47 * @param {Boolean} [options.sortable=true] is it possible to sort the table by clicking on column headers
48 * @param {Boolean} [options.reorderable=true] is it possible to reorder list rows
50 init: function(parent, dataset, view_id, options) {
53 this.set_default_options(_.extend({}, this.defaults, options || {}));
54 this.dataset = dataset;
55 this.model = dataset.model;
56 this.view_id = view_id;
57 this.previous_colspan = null;
62 this.records = new Collection();
64 this.set_groups(new openerp.web.ListView.Groups(this));
66 if (this.dataset instanceof openerp.web.DataSetStatic) {
67 this.groups.datagroup = new openerp.web.StaticDataGroup(this.dataset);
69 this.groups.datagroup = new openerp.web.DataGroup(
72 dataset.get_context(),
74 this.groups.datagroup.sort = this.dataset._sort;
78 this.records.bind('change', function (event, record, key) {
79 if (!_(self.aggregate_columns).chain()
80 .pluck('name').contains(key).value()) {
83 self.compute_aggregates();
86 var domain = [['res_model','=', this.model], ['user_id' , '=', this.session.uid]];
87 new openerp.web.DataSetSearch(
88 this, 'ir.needaction_users_rel', this.dataset.get_context(), domain)
89 .read_slice().done(function(result) {
90 self.needaction_ids = _.pluck(result, 'res_id');
94 * Retrieves the view's number of records per page (|| section)
96 * options > defaults > parent.action.limit > indefinite
98 * @returns {Number|null}
101 if (this._limit === undefined) {
102 this._limit = (this.options.limit
103 || this.defaults.limit
104 || (this.getParent().action || {}).limit
110 * Set a custom Group construct as the root of the List View.
112 * @param {openerp.web.ListView.Groups} groups
114 set_groups: function (groups) {
117 $(this.groups).unbind("selected deleted action row_link");
121 this.groups = groups;
122 $(this.groups).bind({
123 'selected': function (e, ids, records) {
124 self.do_select(ids, records);
126 'deleted': function (e, ids) {
129 'action': function (e, action_name, id, callback) {
130 self.do_button_action(action_name, id, callback);
132 'row_link': function (e, id, dataset, view) {
133 self.do_activate_record(dataset.index, id, dataset, view);
138 * View startup method, the default behavior is to set the ``oe-listview``
139 * class on its root element and to perform an RPC load call.
141 * @returns {$.Deferred} loading promise
145 this.$element.addClass('oe-listview');
146 return this.reload_view(null, null, true);
149 * Returns the color for the provided record in the current view (from the
150 * ``@colors`` attribute)
152 * @param {Record} record record for the current row
153 * @returns {String} CSS color declaration
155 color_for: function (record) {
156 if (!this.colors) { return ''; }
157 var context = _.extend({}, record.attributes, {
158 uid: this.session.uid,
159 current_date: new Date().toString('yyyy-MM-dd')
160 // TODO: time, datetime, relativedelta
163 if(this.needaction_ids.indexOf(record.attributes.id) >= 0) {
164 style = 'font-weight: bold;';
166 for(var i=0, len=this.colors.length; i<len; ++i) {
167 var pair = this.colors[i],
169 expression = pair[1];
170 if (py.evaluate(expression, context).toJSON()) {
171 style += 'color: ' + color + ';'
174 // TODO: handle evaluation errors
179 * Called after loading the list view's description, sets up such things
180 * as the view table's columns, renders the table itself and hooks up the
181 * various table-level and row-level DOM events (action buttons, deletion
182 * buttons, selection of records, [New] button, selection of a given
185 * Sets up the following:
187 * * Processes arch and fields to generate a complete field descriptor for each field
188 * * Create the table itself and allocate visible columns
189 * * Hook in the top-level (header) [New|Add] and [Delete] button
190 * * Sets up showing/hiding the top-level [Delete] button based on records being selected or not
191 * * Sets up event handlers for action buttons and per-row deletion button
192 * * Hooks global callback for clicking on a row
193 * * Sets up its sidebar, if any
195 * @param {Object} data wrapped fields_view_get result
196 * @param {Object} data.fields_view fields_view_get result (processed)
197 * @param {Object} data.fields_view.fields mapping of fields for the current model
198 * @param {Object} data.fields_view.arch current list view descriptor
199 * @param {Boolean} grouped Is the list view grouped
201 on_loaded: function(data, grouped) {
203 this.fields_view = data;
204 this.name = "" + this.fields_view.arch.attrs.string;
206 if (this.fields_view.arch.attrs.colors) {
207 this.colors = _(this.fields_view.arch.attrs.colors.split(';')).chain()
209 .map(function(color_pair) {
210 var pair = color_pair.split(':'),
213 return [color, py.parse(py.tokenize(expr)), expr];
217 this.setup_columns(this.fields_view.fields, grouped);
219 this.$element.html(QWeb.render(this._template, this));
221 this.$element.find('.all-record-selector').click(function(){
222 self.$element.find('.oe-record-selector input').prop('checked',
223 self.$element.find('.all-record-selector').prop('checked') || false);
224 var selection = self.groups.get_selection();
225 $(self.groups).trigger(
226 'selected', [selection.ids, selection.records]);
229 this.$element.find('.oe-list-add')
230 .click(this.proxy('do_add_record'))
231 .attr('disabled', grouped && this.options.editable);
232 this.$element.find('.oe-list-delete')
233 .attr('disabled', true)
234 .click(this.proxy('do_delete_selected'));
235 this.$element.find('thead').delegate('th.oe-sortable[data-id]', 'click', function (e) {
239 self.dataset.sort($this.data('id'));
240 if ($this.find('span').length) {
241 $this.find('span').toggleClass(
242 'ui-icon-triangle-1-s ui-icon-triangle-1-n');
244 $this.append('<span class="ui-icon ui-icon-triangle-1-n">')
245 .siblings('.oe-sortable').find('span').remove();
248 self.reload_content();
251 this.$element.find('.oe-list-pager')
252 .delegate('button', 'click', function () {
254 switch ($this.data('pager-action')) {
256 self.page = 0; break;
258 self.page = Math.floor(
259 self.dataset.size() / self.limit());
262 self.page += 1; break;
264 self.page -= 1; break;
266 self.reload_content();
267 }).find('.oe-pager-state')
268 .click(function (e) {
272 var $select = $('<select>')
273 .appendTo($this.empty())
274 .click(function (e) {e.stopPropagation();})
275 .append('<option value="80">80</option>' +
276 '<option value="100">100</option>' +
277 '<option value="200">200</option>' +
278 '<option value="500">500</option>' +
279 '<option value="NaN">' + _t("Unlimited") + '</option>')
280 .change(function () {
281 var val = parseInt($select.val(), 10);
282 self._limit = (isNaN(val) ? null : val);
284 self.reload_content();
286 .val(self._limit || 'NaN');
288 if (!this.sidebar && this.options.sidebar && this.options.sidebar_id) {
289 this.sidebar = new openerp.web.Sidebar(this, this.options.sidebar_id);
290 this.sidebar.start();
291 this.sidebar.add_toolbar(this.fields_view.toolbar);
292 this.set_common_sidebar_sections(this.sidebar);
296 * Configures the ListView pager based on the provided dataset's information
298 * Horrifying side-effect: sets the dataset's data on this.dataset?
300 * @param {openerp.web.DataSet} dataset
302 configure_pager: function (dataset) {
303 this.dataset.ids = dataset.ids;
305 if (dataset._length) {
306 this.dataset._length = dataset._length;
309 var limit = this.limit(),
310 total = dataset.size(),
311 first = (this.page * limit),
313 if (!limit || (total - first) < limit) {
316 last = first + limit;
318 this.$element.find('span.oe-pager-state').empty().text(_.str.sprintf(
319 _t("[%(first_record)d to %(last_record)d] of %(records_count)d"), {
320 first_record: first + 1,
326 .find('button[data-pager-action=first], button[data-pager-action=previous]')
327 .attr('disabled', this.page === 0)
329 .find('button[data-pager-action=last], button[data-pager-action=next]')
330 .attr('disabled', last === total);
333 * Sets up the listview's columns: merges view and fields data, move
334 * grouped-by columns to the front of the columns list and make them all
337 * @param {Object} fields fields_view_get's fields section
338 * @param {Boolean} [grouped] Should the grouping columns (group and count) be displayed
340 setup_columns: function (fields, grouped) {
341 var domain_computer = openerp.web.form.compute_domain;
343 var noop = function () { return {}; };
344 var field_to_column = function (field) {
345 var name = field.attrs.name;
346 var column = _.extend({id: name, tag: field.tag},
347 fields[name], field.attrs);
348 // modifiers computer
349 if (column.modifiers) {
350 var modifiers = JSON.parse(column.modifiers);
351 column.modifiers_for = function (fields) {
352 if (!modifiers.invisible) {
356 'invisible': domain_computer(modifiers.invisible, fields)
359 if (modifiers['tree_invisible']) {
360 column.invisible = '1';
362 delete column.invisible;
365 column.modifiers_for = noop;
370 this.columns.splice(0, this.columns.length);
371 this.columns.push.apply(
373 _(this.fields_view.arch.children).map(field_to_column));
375 this.columns.unshift({
376 id: '_group', tag: '', string: _t("Group"), meta: true,
377 modifiers_for: function () { return {}; }
379 id: '_count', tag: '', string: '#', meta: true,
380 modifiers_for: function () { return {}; }
384 this.visible_columns = _.filter(this.columns, function (column) {
385 return column.invisible !== '1';
388 this.aggregate_columns = _(this.visible_columns)
389 .map(function (column) {
390 if (column.type !== 'integer' && column.type !== 'float') {
393 var aggregation_func = column['group_operator'] || 'sum';
394 if (!(aggregation_func in column)) {
398 return _.extend({}, column, {
399 'function': aggregation_func,
400 label: column[aggregation_func]
405 * Used to handle a click on a table row, if no other handler caught the
408 * The default implementation asks the list view's view manager to switch
409 * to a different view (by calling
410 * :js:func:`~openerp.web.ViewManager.on_mode_switch`), using the
411 * provided record index (within the current list view's dataset).
413 * If the index is null, ``switch_to_record`` asks for the creation of a
416 * @param {Number|void} index the record index (in the current dataset) to switch to
417 * @param {String} [view="page"] the view type to switch to
419 select_record:function (index, view) {
420 view = view || index == null ? 'form' : 'page';
421 this.dataset.index = index;
422 _.delay(_.bind(function () {
423 this.do_switch_view(view);
426 do_show: function () {
429 this.sidebar.$element.show();
432 do_hide: function () {
435 this.sidebar.$element.hide();
439 * Reloads the list view based on the current settings (dataset & al)
441 * @param {Boolean} [grouped] Should the list be displayed grouped
442 * @param {Object} [context] context to send the server while loading the view
444 reload_view: function (grouped, context, initial) {
446 var callback = function (field_view_get) {
447 self.on_loaded(field_view_get, grouped);
449 if (this.embedded_view) {
450 return $.Deferred().then(callback).resolve(this.embedded_view);
452 return this.rpc('/web/listview/load', {
454 view_id: this.view_id,
456 context: this.dataset.get_context(context),
457 toolbar: this.options.sidebar
462 * re-renders the content of the list view
464 * @returns {$.Deferred} promise to content reloading
466 reload_content: function () {
468 self.$element.find('.all-record-selector').prop('checked', false);
469 this.records.reset();
470 var reloaded = $.Deferred();
471 this.$element.find('.oe-listview-content').append(
472 this.groups.render(function () {
473 if (self.dataset.index == null) {
475 self.records.each(function () { has_one = true; });
477 self.dataset.index = 0;
480 self.compute_aggregates();
487 return reloaded.promise();
489 reload: function () {
490 return this.reload_content();
493 do_load_state: function(state, warm) {
495 if (state.page && this.page !== state.page) {
496 this.page = state.page;
500 if (_.isString(state.limit)) {
503 if (state.limit !== this._limit) {
504 this._limit = state.limit;
509 this.reload_content();
513 * Handler for the result of eval_domain_and_context, actually perform the
516 * @param {Object} results results of evaluating domain and process for a search
518 do_search: function (domain, context, group_by) {
520 this.groups.datagroup = new openerp.web.DataGroup(
521 this, this.model, domain, context, group_by);
522 this.groups.datagroup.sort = this.dataset._sort;
524 if (_.isEmpty(group_by) && !context['group_by_no_leaf']) {
527 this.no_leaf = !!context['group_by_no_leaf'];
529 this.reload_view(!!group_by, context).then(
530 this.proxy('reload_content'));
533 * Handles the signal to delete lines from the records list
535 * @param {Array} ids the ids of the records to delete
537 do_delete: function (ids) {
538 if (!(ids.length && confirm(_t("Do you really want to remove these records?")))) {
542 return $.when(this.dataset.unlink(ids)).then(function () {
543 _(ids).each(function (id) {
544 self.records.remove(self.records.get(id));
546 self.configure_pager(self.dataset);
547 self.compute_aggregates();
551 * Handles the signal indicating that a new record has been selected
553 * @param {Array} ids selected record ids
554 * @param {Array} records selected record values
556 do_select: function (ids, records) {
557 this.$element.find('.oe-list-delete').attr('disabled', !ids.length);
559 this.dataset.index = 0;
560 if (this.sidebar) { this.sidebar.do_fold(); }
561 this.compute_aggregates();
565 this.dataset.index = _(this.dataset.ids).indexOf(ids[0]);
566 if (this.sidebar) { this.sidebar.do_unfold(); }
568 this.compute_aggregates(_(records).map(function (record) {
569 return {count: 1, values: record};
573 * Handles action button signals on a record
575 * @param {String} name action name
576 * @param {Object} id id of the record the action should be called on
577 * @param {Function} callback should be called after the action is executed, if non-null
579 do_button_action: function (name, id, callback) {
580 var action = _.detect(this.columns, function (field) {
581 return field.name === name;
583 if (!action) { return; }
584 if ('confirm' in action && !window.confirm(action.confirm)) {
588 var c = new openerp.web.CompoundContext();
589 c.set_eval_context(_.extend({
592 active_model: this.dataset.model
593 }, this.records.get(id).toContext()));
594 if (action.context) {
595 c.add(action.context);
598 this.do_execute_action(action, this.dataset, id, callback);
601 * Handles the activation of a record (clicking on it)
603 * @param {Number} index index of the record in the dataset
604 * @param {Object} id identifier of the activated record
605 * @param {openerp.web.DataSet} dataset dataset in which the record is available (may not be the listview's dataset in case of nested groups)
607 do_activate_record: function (index, id, dataset, view) {
608 this.dataset.ids = dataset.ids;
609 this.select_record(index, view);
612 * Handles signal for the addition of a new record (can be a creation,
613 * can be the addition from a remote source, ...)
615 * The default implementation is to switch to a new record on the form view
617 do_add_record: function () {
618 this.select_record(null);
621 * Handles deletion of all selected lines
623 do_delete_selected: function () {
624 this.do_delete(this.groups.get_selection().ids);
627 * Computes the aggregates for the current list view, either on the
628 * records provided or on the records of the internal
629 * :js:class:`~openerp.web.ListView.Group`, by calling
630 * :js:func:`~openerp.web.ListView.group.get_records`.
632 * Then displays the aggregates in the table through
633 * :js:method:`~openerp.web.ListView.display_aggregates`.
635 * @param {Array} [records]
637 compute_aggregates: function (records) {
638 var columns = _(this.aggregate_columns).filter(function (column) {
639 return column['function']; });
640 if (_.isEmpty(columns)) { return; }
642 if (_.isEmpty(records)) {
643 records = this.groups.get_records();
645 records = _(records).compact();
647 var count = 0, sums = {};
648 _(columns).each(function (column) {
649 switch (column['function']) {
651 sums[column.id] = -Infinity;
654 sums[column.id] = Infinity;
660 _(records).each(function (record) {
661 count += record.count || 1;
662 _(columns).each(function (column) {
663 var field = column.id,
664 value = record.values[field];
665 switch (column['function']) {
667 sums[field] += value;
670 sums[field] += record.count * value;
673 if (sums[field] > value) {
678 if (sums[field] < value) {
687 _(columns).each(function (column) {
688 var field = column.id;
689 switch (column['function']) {
691 aggregates[field] = {value: sums[field] / count};
694 aggregates[field] = {value: sums[field]};
698 this.display_aggregates(aggregates);
700 display_aggregates: function (aggregation) {
702 var $footer_cells = this.$element.find('.oe-list-footer');
703 _(this.aggregate_columns).each(function (column) {
704 if (!column['function']) {
708 $footer_cells.filter(_.str.sprintf('[data-field=%s]', column.id))
709 .html(openerp.web.format_cell(aggregation, column, {
710 process_modifiers: false
714 get_selected_ids: function() {
715 var ids = this.groups.get_selection().ids;
719 * Adds padding columns at the start or end of all table rows (including
722 * @param {Number} count number of columns to add
723 * @param {Object} options
724 * @param {"before"|"after"} [options.position="after"] insertion position for the new columns
725 * @param {Object} [options.except] content row to not pad
727 pad_columns: function (count, options) {
728 options = options || {};
729 // padding for action/pager header
730 var $first_header = this.$element.find('thead tr:first th');
731 var colspan = $first_header.attr('colspan');
733 if (!this.previous_colspan) {
734 this.previous_colspan = colspan;
736 $first_header.attr('colspan', parseInt(colspan, 10) + count);
738 // Padding for column titles, footer and data rows
739 var $rows = this.$element
740 .find('.oe-listview-header-columns, tr:not(thead tr)')
741 .not(options['except']);
742 var newcols = new Array(count+1).join('<td class="oe-listview-padding"></td>');
743 if (options.position === 'before') {
744 $rows.prepend(newcols);
746 $rows.append(newcols);
750 * Removes all padding columns of the table
752 unpad_columns: function () {
753 this.$element.find('.oe-listview-padding').remove();
754 if (this.previous_colspan) {
756 .find('thead tr:first th')
757 .attr('colspan', this.previous_colspan);
758 this.previous_colspan = null;
762 openerp.web.ListView.List = openerp.web.Class.extend( /** @lends openerp.web.ListView.List# */{
764 * List display for the ListView, handles basic DOM events and transforms
765 * them in the relevant higher-level events, to which the list view (or
766 * other consumers) can subscribe.
768 * Events on this object are registered via jQuery.
773 * Triggered when a row is selected (using check boxes), provides an
774 * array of ids of all the selected records.
776 * Triggered when deletion buttons are hit, provide an array of ids of
777 * all the records being marked for suppression.
779 * Triggered when an action button is clicked, provides two parameters:
781 * * The name of the action to execute (as a string)
782 * * The id of the record to execute the action on
784 * Triggered when a row of the table is clicked, provides the index (in
785 * the rows array) and id of the selected record to the handle function.
787 * @constructs openerp.web.ListView.List
788 * @extends openerp.web.Class
790 * @param {Object} opts display options, identical to those of :js:class:`openerp.web.ListView`
792 init: function (group, opts) {
795 this.view = group.view;
796 this.session = this.view.session;
798 this.options = opts.options;
799 this.columns = opts.columns;
800 this.dataset = opts.dataset;
801 this.records = opts.records;
803 this.record_callbacks = {
804 'remove': function (event, record) {
805 var $row = self.$current.find(
806 '[data-id=' + record.get('id') + ']');
807 var index = $row.data('index');
809 self.refresh_zebra(index);
811 'reset': function () { return self.on_records_reset(); },
812 'change': function (event, record) {
813 var $row = self.$current.find('[data-id=' + record.get('id') + ']');
814 $row.replaceWith(self.render_record(record));
816 'add': function (ev, records, record, index) {
817 var $new_row = $('<tr>').attr({
818 'data-id': record.get('id')
822 $new_row.prependTo(self.$current);
824 var previous_record = records.at(index-1),
825 $previous_sibling = self.$current.find(
826 '[data-id=' + previous_record.get('id') + ']');
827 $new_row.insertAfter($previous_sibling);
830 self.refresh_zebra(index, 1);
833 _(this.record_callbacks).each(function (callback, event) {
834 this.records.bind(event, callback);
837 this.$_element = $('<tbody class="ui-widget-content">')
838 .appendTo(document.body)
839 .delegate('th.oe-record-selector', 'click', function (e) {
841 var selection = self.get_selection();
843 'selected', [selection.ids, selection.records]);
845 .delegate('td.oe-record-delete button', 'click', function (e) {
847 var $row = $(e.target).closest('tr');
848 $(self).trigger('deleted', [[self.row_id($row)]]);
850 .delegate('td.oe-field-cell button', 'click', function (e) {
852 var $target = $(e.currentTarget),
853 field = $target.closest('td').data('field'),
854 $row = $target.closest('tr'),
855 record_id = self.row_id($row);
857 // note: $.data converts data to number if it's composed only
858 // of digits, nice when storing actual numbers, not nice when
859 // storing strings composed only of digits. Force the action
860 // name to be a string
861 $(self).trigger('action', [field.toString(), record_id, function () {
862 return self.reload_record(self.records.get(record_id));
865 .delegate('a', 'click', function (e) {
868 .delegate('tr', 'click', function (e) {
870 var row_id = self.row_id(e.currentTarget);
871 if (row_id !== undefined) {
872 if (!self.dataset.select_id(row_id)) {
873 throw "Could not find id in dataset"
876 if ($(e.target).is('.oe-record-edit-link-img')) {
879 self.row_clicked(e, view);
883 row_clicked: function (e, view) {
886 [this.dataset.ids[this.dataset.index],
887 this.dataset, view]);
889 render_cell: function (record, column) {
891 if(column.type === 'reference') {
892 value = record.get(column.id);
894 // Ensure that value is in a reference "shape", otherwise we're
895 // going to loop on performing name_get after we've resolved (and
896 // set) a human-readable version. m2o does not have this issue
897 // because the non-human-readable is just a number, where the
898 // human-readable version is a pair
899 if (value && (ref_match = /([\w\.]+),(\d+)/.exec(value))) {
900 // reference values are in the shape "$model,$id" (as a
901 // string), we need to split and name_get this pair in order
902 // to get a correctly displayable value in the field
903 var model = ref_match[1],
904 id = parseInt(ref_match[2], 10);
905 new openerp.web.DataSet(this.view, model).name_get([id], function(names) {
906 if (!names.length) { return; }
907 record.set(column.id, names[0][1]);
910 } else if (column.type === 'many2one') {
911 value = record.get(column.id);
912 // m2o values are usually name_get formatted, [Number, String]
913 // pairs, but in some cases only the id is provided. In these
914 // cases, we need to perform a name_get call to fetch the actual
916 if (typeof value === 'number' || value instanceof Number) {
917 // fetch the name, set it on the record (in the right field)
918 // and let the various registered events handle refreshing the
920 new openerp.web.DataSet(this.view, column.relation)
921 .name_get([value], function (names) {
922 if (!names.length) { return; }
923 record.set(column.id, names[0]);
927 return openerp.web.format_cell(record.toForm().data, column, {
928 model: this.dataset.model,
932 render: function () {
935 this.$current.remove();
937 this.$current = this.$_element.clone(true);
938 this.$current.empty().append(
939 QWeb.render('ListView.rows', _.extend({
940 render_cell: function () {
941 return self.render_cell.apply(self, arguments); }
943 this.pad_table_to(5);
945 pad_table_to: function (count) {
946 if (this.records.length >= count ||
947 _(this.columns).any(function(column) { return column.meta; })) {
951 if (this.options.selectable) {
952 cells.push('<th class="oe-record-selector"></td>');
954 if (this.options.isClarkGable) {
955 cells.push('<th class="oe-record-edit-link"></td>');
957 _(this.columns).each(function(column) {
958 if (column.invisible === '1') {
961 if (column.tag === 'button') {
962 cells.push('<td class="oe-button" title="' + column.string + '"> </td>');
964 cells.push('<td title="' + column.string + '"> </td>');
967 if (this.options.deletable) {
968 cells.push('<td class="oe-record-delete"><button type="button" style="visibility: hidden"> </button></td>');
970 cells.unshift('<tr>');
973 var row = cells.join('');
975 .children('tr:not([data-id])').remove().end()
976 .append(new Array(count - this.records.length + 1).join(row));
977 this.refresh_zebra(this.records.length);
980 * Gets the ids of all currently selected records, if any
981 * @returns {Object} object with the keys ``ids`` and ``records``, holding respectively the ids of all selected records and the records themselves.
983 get_selection: function () {
984 if (!this.options.selectable) {
987 var records = this.records;
988 var result = {ids: [], records: []};
989 this.$current.find('th.oe-record-selector input:checked')
990 .closest('tr').each(function () {
991 var record = records.get($(this).data('id'));
992 result.ids.push(record.get('id'));
993 result.records.push(record.attributes);
998 * Returns the identifier of the object displayed in the provided table
1001 * @param {Object} row the selected table row
1002 * @returns {Number|String} the identifier of the row's object
1004 row_id: function (row) {
1005 return $(row).data('id');
1008 * Death signal, cleans up list display
1010 on_records_reset: function () {
1011 _(this.record_callbacks).each(function (callback, event) {
1012 this.records.unbind(event, callback);
1014 if (!this.$current) { return; }
1015 this.$current.remove();
1016 this.$current = null;
1017 this.$_element.remove();
1019 get_records: function () {
1020 return this.records.map(function (record) {
1021 return {count: 1, values: record.attributes};
1025 * Reloads the provided record by re-reading its content from the server.
1027 * @param {Record} record
1028 * @returns {$.Deferred} promise to the finalization of the reloading
1030 reload_record: function (record) {
1031 return this.dataset.read_ids(
1033 _.pluck(_(this.columns).filter(function (r) {
1034 return r.tag === 'field';
1036 ).then(function (records) {
1037 _(records[0]).each(function (value, key) {
1038 record.set(key, value, {silent: true});
1040 record.trigger('change', record);
1044 * Renders a list record to HTML
1046 * @param {Record} record index of the record to render in ``this.rows``
1047 * @returns {String} QWeb rendering of the selected record
1049 render_record: function (record) {
1050 var index = this.records.indexOf(record);
1051 return QWeb.render('ListView.row', {
1052 columns: this.columns,
1053 options: this.options,
1055 row_parity: (index % 2 === 0) ? 'even' : 'odd',
1057 render_cell: function () {
1058 return this.render_cell.apply(this, arguments); }
1062 * Fixes fixes the even/odd classes
1064 * @param {Number} [from_index] index from which to resequence
1065 * @param {Number} [offset = 0] selection offset for DOM, in case there are rows to ignore in the table
1067 refresh_zebra: function (from_index, offset) {
1068 offset = offset || 0;
1069 from_index = from_index || 0;
1070 var dom_offset = offset + from_index;
1071 var sel = dom_offset ? ':gt(' + (dom_offset - 1) + ')' : null;
1072 this.$current.children(sel).each(function (i, e) {
1073 var index = from_index + i;
1074 // reset record-index accelerators on rows and even/odd
1075 var even = index%2 === 0;
1076 $(e).toggleClass('even', even)
1077 .toggleClass('odd', !even);
1081 openerp.web.ListView.Groups = openerp.web.Class.extend( /** @lends openerp.web.ListView.Groups# */{
1082 passtrough_events: 'action deleted row_link',
1084 * Grouped display for the ListView. Handles basic DOM events and interacts
1085 * with the :js:class:`~openerp.web.DataGroup` bound to it.
1087 * Provides events similar to those of
1088 * :js:class:`~openerp.web.ListView.List`
1090 * @constructs openerp.web.ListView.Groups
1091 * @extends openerp.web.Class
1093 * @param {openerp.web.ListView} view
1094 * @param {Object} [options]
1095 * @param {Collection} [options.records]
1096 * @param {Object} [options.options]
1097 * @param {Array} [options.columns]
1099 init: function (view, options) {
1100 options = options || {};
1102 this.records = options.records || view.records;
1103 this.options = options.options || view.options;
1104 this.columns = options.columns || view.columns;
1105 this.datagroup = null;
1113 this.records.bind('reset', function () {
1114 return self.on_records_reset(); });
1116 make_fragment: function () {
1117 return document.createDocumentFragment();
1120 * Returns a DOM node after which a new tbody can be inserted, so that it
1121 * follows the provided row.
1123 * Necessary to insert the result of a new group or list view within an
1124 * existing groups render, without losing track of the groups's own
1127 * @param {HTMLTableRowElement} row the row after which the caller wants to insert a body
1128 * @returns {HTMLTableSectionElement} element after which a tbody can be inserted
1130 point_insertion: function (row) {
1132 var red_letter_tboday = $row.closest('tbody')[0];
1134 var $next_siblings = $row.nextAll();
1135 if ($next_siblings.length) {
1136 var $root_kanal = $('<tbody>').insertAfter(red_letter_tboday);
1138 $root_kanal.append($next_siblings);
1139 this.elements.splice(
1140 _.indexOf(this.elements, red_letter_tboday),
1144 return red_letter_tboday;
1146 make_paginator: function () {
1148 var $prev = $('<button type="button" data-pager-action="previous"><</button>')
1149 .click(function (e) {
1150 e.stopPropagation();
1153 self.$row.closest('tbody').next()
1154 .replaceWith(self.render());
1156 var $next = $('<button type="button" data-pager-action="next">></button>')
1157 .click(function (e) {
1158 e.stopPropagation();
1161 self.$row.closest('tbody').next()
1162 .replaceWith(self.render());
1164 this.$row.children().last()
1166 .append('<span class="oe-pager-state"></span>')
1169 open: function (point_insertion) {
1170 this.render().insertAfter(point_insertion);
1172 var no_subgroups = _(this.datagroup.group_by).isEmpty(),
1173 records_terminated = !this.datagroup.context['group_by_no_leaf'];
1174 if (no_subgroups && records_terminated) {
1175 this.make_paginator();
1178 close: function () {
1179 this.$row.children().last().empty();
1180 this.records.reset();
1183 * Prefixes ``$node`` with floated spaces in order to indent it relative
1184 * to its own left margin/baseline
1186 * @param {jQuery} $node jQuery object to indent
1187 * @param {Number} level current nesting level, >= 1
1188 * @returns {jQuery} the indentation node created
1190 indent: function ($node, level) {
1192 .css({'float': 'left', 'white-space': 'pre'})
1193 .text(new Array(level).join(' '))
1196 render_groups: function (datagroups) {
1198 var placeholder = this.make_fragment();
1199 _(datagroups).each(function (group) {
1200 if (self.children[group.value]) {
1201 self.records.proxy(group.value).reset();
1202 delete self.children[group.value];
1204 var child = self.children[group.value] = new openerp.web.ListView.Groups(self.view, {
1205 records: self.records.proxy(group.value),
1206 options: self.options,
1207 columns: self.columns
1209 self.bind_child_events(child);
1210 child.datagroup = group;
1212 var $row = child.$row = $('<tr>');
1213 if (group.openable && group.length) {
1214 $row.click(function (e) {
1215 if (!$row.data('open')) {
1216 $row.data('open', true)
1217 .find('span.ui-icon')
1218 .removeClass('ui-icon-triangle-1-e')
1219 .addClass('ui-icon-triangle-1-s');
1220 child.open(self.point_insertion(e.currentTarget));
1222 $row.removeData('open')
1223 .find('span.ui-icon')
1224 .removeClass('ui-icon-triangle-1-s')
1225 .addClass('ui-icon-triangle-1-e');
1230 placeholder.appendChild($row[0]);
1232 var $group_column = $('<th class="oe-group-name">').appendTo($row);
1233 // Don't fill this if group_by_no_leaf but no group_by
1234 if (group.grouped_on) {
1236 row_data[group.grouped_on] = group;
1237 var group_column = _(self.columns).detect(function (column) {
1238 return column.id === group.grouped_on; });
1240 $group_column.html(openerp.web.format_cell(
1241 row_data, group_column, {
1242 value_if_empty: _t("Undefined"),
1243 process_modifiers: false
1246 $group_column.html(row_data[group_column.id].value);
1248 if (group.length && group.openable) {
1249 // Make openable if not terminal group & group_by_no_leaf
1250 $group_column.prepend('<span class="ui-icon ui-icon-triangle-1-e" style="float: left;">');
1252 // Kinda-ugly hack: jquery-ui has no "empty" icon, so set
1253 // wonky background position to ensure nothing is displayed
1254 // there but the rest of the behavior is ui-icon's
1255 $group_column.prepend('<span class="ui-icon" style="float: left; background-position: 150px 150px">');
1258 self.indent($group_column, group.level);
1260 $('<td>').text(group.length).appendTo($row);
1262 if (self.options.selectable) {
1263 $row.append('<td>');
1265 if (self.options.isClarkGable) {
1266 $row.append('<td>');
1268 _(self.columns).chain()
1269 .filter(function (column) {return !column.invisible;})
1270 .each(function (column) {
1272 // do not do anything
1273 } else if (column.id in group.aggregates) {
1275 r[column.id] = {value: group.aggregates[column.id]};
1276 $('<td class="oe-number">')
1277 .html(openerp.web.format_cell(
1278 r, column, {process_modifiers: false}))
1281 $row.append('<td>');
1284 if (self.options.deletable) {
1285 $row.append('<td class="oe-group-pagination">');
1290 bind_child_events: function (child) {
1291 var $this = $(this),
1293 $(child).bind('selected', function (e) {
1294 // can have selections spanning multiple links
1295 var selection = self.get_selection();
1296 $this.trigger(e, [selection.ids, selection.records]);
1297 }).bind(this.passtrough_events, function (e) {
1298 // additional positional parameters are provided to trigger as an
1299 // Array, following the event type or event object, but are
1300 // provided to the .bind event handler as *args.
1301 // Convert our *args back into an Array in order to trigger them
1302 // on the group itself, so it can ultimately be forwarded wherever
1303 // it's supposed to go.
1304 var args = Array.prototype.slice.call(arguments, 1);
1305 $this.trigger.call($this, e, args);
1308 render_dataset: function (dataset) {
1310 list = new openerp.web.ListView.List(this, {
1311 options: this.options,
1312 columns: this.columns,
1314 records: this.records
1316 this.bind_child_events(list);
1318 var view = this.view,
1319 limit = view.limit(),
1320 d = new $.Deferred(),
1321 page = this.datagroup.openable ? this.page : view.page;
1323 var fields = _.pluck(_.select(this.columns, function(x) {return x.tag == "field"}), 'name');
1324 var options = { offset: page * limit, limit: limit, context: {bin_size: true} };
1325 //TODO xmo: investigate why we need to put the setTimeout
1326 $.async_when().then(function() {dataset.read_slice(fields, options).then(function (records) {
1327 // FIXME: ignominious hacks, parents (aka form view) should not send two ListView#reload_content concurrently
1328 if (self.records.length) {
1329 self.records.reset(null, {silent: true});
1331 if (!self.datagroup.openable) {
1332 view.configure_pager(dataset);
1334 if (dataset.size() == records.length) {
1336 self.$row.find('td.oe-group-pagination').empty();
1338 var pages = Math.ceil(dataset.size() / limit);
1340 .find('.oe-pager-state')
1341 .text(_.str.sprintf(_t("%(page)d/%(page_count)d"), {
1346 .find('button[data-pager-action=previous]')
1347 .attr('disabled', page === 0)
1349 .find('button[data-pager-action=next]')
1350 .attr('disabled', page === pages - 1);
1354 self.records.add(records, {silent: true});
1360 setup_resequence_rows: function (list, dataset) {
1361 // drag and drop enabled if list is not sorted and there is a
1362 // "sequence" column in the view.
1363 if ((dataset.sort && dataset.sort())
1364 || !_(this.columns).any(function (column) {
1365 return column.name === 'sequence'; })) {
1368 // ondrop, move relevant record & fix sequences
1369 list.$current.sortable({
1371 items: '> tr[data-id]',
1372 containment: 'parent',
1374 stop: function (event, ui) {
1375 var to_move = list.records.get(ui.item.data('id')),
1376 target_id = ui.item.prev().data('id'),
1377 from_index = list.records.indexOf(to_move),
1378 target = list.records.get(target_id);
1379 if (list.records.at(from_index - 1) == target) {
1383 list.records.remove(to_move);
1384 var to = target_id ? list.records.indexOf(target) + 1 : 0;
1385 list.records.add(to_move, { at: to });
1387 // resequencing time!
1388 var record, index = to,
1389 // if drag to 1st row (to = 0), start sequencing from 0
1390 // (exclusive lower bound)
1391 seq = to ? list.records.at(to - 1).get('sequence') : 0;
1392 while (++seq, record = list.records.at(index++)) {
1393 // write are independent from one another, so we can just
1394 // launch them all at the same time and we don't really
1395 // give a fig about when they're done
1396 // FIXME: breaks on o2ms (e.g. Accounting > Financial
1397 // Accounting > Taxes > Taxes, child tax accounts)
1398 // when synchronous (without setTimeout)
1399 (function (dataset, id, seq) {
1400 $.async_when().then(function () {
1401 dataset.write(id, {sequence: seq});
1403 }(dataset, record.get('id'), seq));
1404 record.set('sequence', seq);
1407 list.refresh_zebra();
1411 render: function (post_render) {
1413 var $element = $('<tbody>');
1414 this.elements = [$element[0]];
1416 this.datagroup.list(
1417 _(this.view.visible_columns).chain()
1418 .filter(function (column) { return column.tag === 'field' })
1419 .pluck('name').value(),
1421 $element[0].appendChild(
1422 self.render_groups(groups));
1423 if (post_render) { post_render(); }
1424 }, function (dataset) {
1425 self.render_dataset(dataset).then(function (list) {
1426 self.children[null] = list;
1428 [list.$current.replaceAll($element)[0]];
1429 self.setup_resequence_rows(list, dataset);
1430 if (post_render) { post_render(); }
1436 * Returns the ids of all selected records for this group, and the records
1439 get_selection: function () {
1440 var ids = [], records = [];
1443 .each(function (child) {
1444 var selection = child.get_selection();
1445 ids.push.apply(ids, selection.ids);
1446 records.push.apply(records, selection.records);
1449 return {ids: ids, records: records};
1451 on_records_reset: function () {
1453 $(this.elements).remove();
1455 get_records: function () {
1456 if (_(this.children).isEmpty()) {
1457 if (!this.datagroup.length) {
1461 count: this.datagroup.length,
1462 values: this.datagroup.aggregates
1465 return _(this.children).chain()
1466 .map(function (child) {
1467 return child.get_records();
1468 }).flatten().value();
1475 var Events = /** @lends Events# */{
1477 * @param {String} event event to listen to on the current object, null for all events
1478 * @param {Function} handler event handler to bind to the relevant event
1481 bind: function (event, handler) {
1482 var calls = this['_callbacks'] || (this._callbacks = {});
1484 if (event in calls) {
1485 calls[event].push(handler);
1487 calls[event] = [handler];
1492 * @param {String} event event to unbind on the current object
1493 * @param {function} [handler] specific event handler to remove (otherwise unbind all handlers for the event)
1496 unbind: function (event, handler) {
1497 var calls = this._callbacks || {};
1498 if (!(event in calls)) { return this; }
1500 delete calls[event];
1502 var handlers = calls[event];
1504 _(handlers).indexOf(handler),
1510 * @param {String} event
1513 trigger: function (event) {
1515 if (!(calls = this._callbacks)) { return this; }
1516 var callbacks = (calls[event] || []).concat(calls[null] || []);
1517 for(var i=0, length=callbacks.length; i<length; ++i) {
1518 callbacks[i].apply(this, arguments);
1523 var Record = openerp.web.Class.extend(/** @lends Record# */{
1525 * @constructs Record
1526 * @extends openerp.web.Class
1529 * @param {Object} [data]
1531 init: function (data) {
1532 this.attributes = data || {};
1535 * @param {String} key
1538 get: function (key) {
1539 return this.attributes[key];
1544 * @param {Object} [options]
1545 * @param {Boolean} [options.silent=false]
1548 set: function (key, value, options) {
1549 options = options || {};
1550 var old_value = this.attributes[key];
1551 if (old_value === value) {
1554 this.attributes[key] = value;
1555 if (!options.silent) {
1556 this.trigger('change:' + key, this, value, old_value);
1557 this.trigger('change', this, key, value, old_value);
1562 * Converts the current record to the format expected by form views:
1564 * .. code-block:: javascript
1573 * @returns {Object} record displayable in a form view
1575 toForm: function () {
1576 var form_data = {}, attrs = this.attributes;
1577 for(var k in attrs) {
1578 form_data[k] = {value: attrs[k]};
1581 return {data: form_data};
1584 * Converts the current record to a format expected by context evaluations
1585 * (identical to record.attributes, except m2o fields are their integer
1586 * value rather than a pair)
1588 toContext: function () {
1589 var output = {}, attrs = this.attributes;
1590 for(var k in attrs) {
1592 if (typeof val !== 'object') {
1594 } else if (val instanceof Array) {
1597 throw new Error("Can't convert value " + val + " to context");
1603 Record.include(Events);
1604 var Collection = openerp.web.Class.extend(/** @lends Collection# */{
1606 * Smarter collections, with events, very strongly inspired by Backbone's.
1608 * Using a "dumb" array of records makes synchronization between the
1611 * @constructs Collection
1612 * @extends openerp.web.Class
1615 * @param {Array} [records] records to initialize the collection with
1616 * @param {Object} [options]
1618 init: function (records, options) {
1619 options = options || {};
1620 _.bindAll(this, '_onRecordEvent');
1625 this._key = options.key;
1626 this._parent = options.parent;
1633 * @param {Object|Array} record
1634 * @param {Object} [options]
1635 * @param {Number} [options.at]
1636 * @param {Boolean} [options.silent=false]
1639 add: function (record, options) {
1640 options = options || {};
1641 var records = record instanceof Array ? record : [record];
1643 for(var i=0, length=records.length; i<length; ++i) {
1644 var instance = (records[i] instanceof Record) ? records[i] : new Record(records[i]);
1645 instance.bind(null, this._onRecordEvent);
1646 this._byId[instance.get('id')] = instance;
1647 if (options.at == undefined) {
1648 this.records.push(instance);
1649 if (!options.silent) {
1650 this.trigger('add', this, instance, this.records.length-1);
1653 var insertion_index = options.at + i;
1654 this.records.splice(insertion_index, 0, instance);
1655 if (!options.silent) {
1656 this.trigger('add', this, instance, insertion_index);
1665 * Get a record by its index in the collection, can also take a group if
1666 * the collection is not degenerate
1668 * @param {Number} index
1669 * @param {String} [group]
1670 * @returns {Record|undefined}
1672 at: function (index, group) {
1674 var groups = group.split('.');
1675 return this._proxies[groups[0]].at(index, groups.join('.'));
1677 return this.records[index];
1680 * Get a record by its database id
1682 * @param {Number} id
1683 * @returns {Record|undefined}
1685 get: function (id) {
1686 if (!_(this._proxies).isEmpty()) {
1688 _(this._proxies).detect(function (proxy) {
1689 return record = proxy.get(id);
1693 return this._byId[id];
1696 * Builds a proxy (insert/retrieve) to a subtree of the collection, by
1697 * the subtree's group
1699 * @param {String} section group path section
1700 * @returns {Collection}
1702 proxy: function (section) {
1703 return this._proxies[section] = new Collection(null, {
1706 }).bind(null, this._onRecordEvent);
1709 * @param {Array} [records]
1712 reset: function (records, options) {
1713 options = options || {};
1714 _(this._proxies).each(function (proxy) {
1718 _(this.records).invoke('unbind', null, this._onRecordEvent);
1725 if (!options.silent) {
1726 this.trigger('reset', this);
1731 * Removes the provided record from the collection
1733 * @param {Record} record
1736 remove: function (record) {
1737 var index = _(this.records).indexOf(record);
1739 _(this._proxies).each(function (proxy) {
1740 proxy.remove(record);
1745 record.unbind(null, this._onRecordEvent);
1746 this.records.splice(index, 1);
1747 delete this._byId[record.get('id')];
1749 this.trigger('remove', record, this);
1753 _onRecordEvent: function (event, record, options) {
1754 // don't propagate reset events
1755 if (event === 'reset') { return; }
1756 this.trigger.apply(this, arguments);
1759 // underscore-type methods
1760 each: function (callback) {
1761 for(var section in this._proxies) {
1762 if (this._proxies.hasOwnProperty(section)) {
1763 this._proxies[section].each(callback);
1766 for(var i=0; i<this.length; ++i) {
1767 callback(this.records[i]);
1770 map: function (callback) {
1772 this.each(function (record) {
1773 results.push(callback(record));
1777 pluck: function (fieldname) {
1778 return this.map(function (record) {
1779 return record.get(fieldname);
1782 indexOf: function (record) {
1783 return _(this.records).indexOf(record);
1786 Collection.include(Events);
1787 openerp.web.list = {
1790 Collection: Collection
1793 // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax: