odoo.define('web.ListView', function (require) { "use strict"; var core = require('web.core'); var data = require('web.data'); var DataExport = require('web.DataExport'); var formats = require('web.formats'); var common = require('web.list_common'); var Model = require('web.DataModel'); var pyeval = require('web.pyeval'); var session = require('web.session'); var Sidebar = require('web.Sidebar'); var utils = require('web.utils'); var View = require('web.View'); var Class = core.Class; var _t = core._t; var _lt = core._lt; var QWeb = core.qweb; var list_widget_registry = core.list_widget_registry; // Allowed decoration on the list's rows: bold, italic and bootstrap semantics classes var row_decoration = [ 'decoration-bf', 'decoration-it', 'decoration-danger', 'decoration-info', 'decoration-muted', 'decoration-primary', 'decoration-success', 'decoration-warning' ]; var ListView = View.extend( /** @lends instance.web.ListView# */ { _template: 'ListView', accesskey: 'L', icon: 'fa-list-ul', display_name: _lt('List'), defaults: { // records can be selected one by one 'selectable': true, // list rows can be deleted 'deletable': false, // whether the column headers should be displayed 'header': true, // display addition button, with that label 'addable': _lt("Create"), // whether the list view can be sorted, note that once a view has been // sorted it can not be reordered anymore 'sortable': true, // whether the view rows can be reordered (via vertical drag & drop) 'reorderable': true, 'action_buttons': true, //whether the editable property of the view has to be disabled 'disable_editable_mode': false, }, view_type: 'tree', events: { 'click thead th.oe_sortable[data-id]': 'sort_by_column' }, /** * Core class for list-type displays. * * As a view, needs a number of view-related parameters to be correctly * instantiated, provides options and overridable methods for behavioral * customization. * * See constructor parameters and method documentations for information on * the default behaviors and possible options for the list view. * * @constructs instance.web.ListView * @extends instance.web.View * * @param parent parent object * @param {instance.web.DataSet} dataset the dataset the view should work with * @param {String} view_id the listview's identifier, if any * @param {Object} options A set of options used to configure the view * @param {Boolean} [options.selectable=true] determines whether view rows are selectable (e.g. via a checkbox) * @param {Boolean} [options.header=true] should the list's header be displayed * @param {Boolean} [options.deletable=true] are the list rows deletable * @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. * @param {Boolean} [options.sortable=true] is it possible to sort the table by clicking on column headers * @param {Boolean} [options.reorderable=true] is it possible to reorder list rows */ init: function(parent, dataset, view_id, options) { var self = this; this._super(parent); this.set_default_options(_.extend({}, this.defaults, options || {})); this.dataset = dataset; this.model = dataset.model; this.view_id = view_id; this.previous_colspan = null; this.colors = null; this.fonts = null; this.columns = []; this.records = new common.Collection(); this.set_groups(new (this.options.GroupsType)(this)); if (this.dataset instanceof data.DataSetStatic) { this.groups.datagroup = new StaticDataGroup(this.dataset); } else { this.groups.datagroup = new DataGroup( this, this.model, dataset.get_domain(), dataset.get_context()); this.groups.datagroup.sort = this.dataset._sort; } this.page = 0; this.records.bind('change', function (event, record, key) { if (!_(self.aggregate_columns).chain() .pluck('name').contains(key).value()) { return; } self.compute_aggregates(); }); this.no_leaf = false; this.grouped = false; if (!this.options.$pager || !this.options.$pager.length) { this.options.$pager = false; } // the view's number of records per page (|| section) this._limit = (this.options.limit || this.defaults.limit || (this.getParent().action || {}).limit || 80); }, view_loading: function(r) { return this.load_list(r); }, set_default_options: function (options) { this._super(options); _.defaults(this.options, { GroupsType: ListView.Groups, ListType: ListView.List }); }, /** * Set a custom Group construct as the root of the List View. * * @param {instance.web.ListView.Groups} groups */ set_groups: function (groups) { var self = this; if (this.groups) { $(this.groups).unbind("selected deleted action row_link"); delete this.groups; } this.groups = groups; $(this.groups).bind({ 'selected': function (e, ids, records, deselected) { self.do_select(ids, records, deselected); }, 'deleted': function (e, ids) { self.do_delete(ids); }, 'action': function (e, action_name, id, callback) { self.do_button_action(action_name, id, callback); }, 'row_link': function (e, id, dataset, view) { self.do_activate_record(dataset.index, id, dataset, view); } }); }, /** * View startup method, the default behavior is to set the ``oe_list`` * class on its root element and to perform an RPC load call. * * @returns {$.Deferred} loading promise */ start: function() { this.$el.addClass('oe_list o_list_view'); return this._super(); }, /** * Computes and returns the classnames for the provided record (from the * ``@decoration`` attribute) * * @param {Record} record record for the current row * @returns {String} classnames */ compute_decoration_classnames: function (record) { var classnames= ''; var context = _.extend({}, record.attributes, { uid: session.uid, current_date: moment().format('YYYY-MM-DD') // TODO: time, datetime, relativedelta }); _.each(this.decoration, function(expr, decoration) { if (py.PY_isTrue(py.evaluate(expr, context))) { classnames += ' ' + decoration.replace('decoration', 'text'); } }); return classnames; }, /** * Called after loading the list view's description, sets up such things * as the view table's columns, renders the table itself and hooks up the * various table-level and row-level DOM events (action buttons, deletion * buttons, selection of records, [New] button, selection of a given * record, ...) * * Sets up the following: * * * Processes arch and fields to generate a complete field descriptor for each field * * Create the table itself and allocate visible columns * * Hook in the top-level (header) [New|Add] and [Delete] button * * Sets up showing/hiding the top-level [Delete] button based on records being selected or not * * Sets up event handlers for action buttons and per-row deletion button * * Hooks global callback for clicking on a row * * @param {Object} data wrapped fields_view_get result * @param {Object} data.fields_view fields_view_get result (processed) * @param {Object} data.fields_view.fields mapping of fields for the current model * @param {Object} data.fields_view.arch current list view descriptor * @param {Boolean} grouped Is the list view grouped */ load_list: function(data) { var self = this; this.fields_view = data; this.name = "" + this.fields_view.arch.attrs.string; this._limit = parseInt(this.fields_view.arch.attrs.limit, 10) || this._limit; // Retrieve the decoration defined on the model's list view this.decoration = _.pick(this.fields_view.arch.attrs, function(value, key) { return row_decoration.indexOf(key) >= 0; }); this.decoration = _.mapObject(this.decoration, function(value) { return py.parse(py.tokenize(value)); }); this.setup_columns(this.fields_view.fields, this.grouped); this.$el.html(QWeb.render(this._template, this)); this.$el.addClass(this.fields_view.arch.attrs['class']); // Head hook // Selecting records this.$el.find('.oe_list_record_selector').click(function(){ self.$el.find('.oe_list_record_selector input').prop('checked', self.$el.find('.oe_list_record_selector').prop('checked') || false); var selection = self.groups.get_selection(); $(self.groups).trigger( 'selected', [selection.ids, selection.records]); }); //Sort var default_order = this.fields_view.arch.attrs.default_order, unsorted = !this.dataset._sort.length; if (unsorted && default_order && !this.grouped) { this.dataset.set_sort(default_order.split(',')); } if(this.dataset._sort.length){ if(this.dataset._sort[0].indexOf('-') == -1){ this.$el.find('th[data-id=' + this.dataset._sort[0] + ']').addClass("sortdown"); }else { this.$el.find('th[data-id=' + this.dataset._sort[0].split('-')[1] + ']').addClass("sortup"); } } this.trigger('list_view_loaded', data, this.grouped); }, /** * Render the buttons according to the ListView.buttons template and * add listeners on it. * Set this.$buttons with the produced jQuery element * @param {jQuery} [$node] a jQuery node where the rendered buttons should be inserted * $node may be undefined, in which case the ListView inserts them into this.options.$buttons * or into a div of its template */ render_buttons: function($node) { if (!this.$buttons) { this.$buttons = $(QWeb.render("ListView.buttons", {'widget': this})); this.$buttons.find('.o_list_button_add').click(this.proxy('do_add_record')); $node = $node || this.options.$buttons; if ($node) { this.$buttons.appendTo($node); } else { this.$('.oe_list_buttons').replaceWith(this.$buttons); } } }, /** * Instantiate and render the sidebar. * Sets this.sidebar * @param {jQuery} [$node] a jQuery node where the sidebar should be inserted * $node may be undefined, in which case the ListView inserts the sidebar in * this.options.$sidebar or in a div of its template **/ render_sidebar: function($node) { if (!this.sidebar && this.options.sidebar) { this.sidebar = new Sidebar(this, {editable: this.is_action_enabled('edit')}); if (this.fields_view.toolbar) { this.sidebar.add_toolbar(this.fields_view.toolbar); } this.sidebar.add_items('other', _.compact([ { label: _t("Export"), callback: this.on_sidebar_export }, this.fields_view.fields.active && {label: _t("Archive"), callback: this.do_archive_selected}, this.fields_view.fields.active && {label: _t("Unarchive"), callback: this.do_unarchive_selected}, this.is_action_enabled('delete') && { label: _t('Delete'), callback: this.do_delete_selected } ])); $node = $node || this.options.$sidebar || this.$('.oe_list_sidebar'); this.sidebar.appendTo($node); // Hide the sidebar by default (it will be shown as soon as a record is selected) this.sidebar.do_hide(); } }, /** * Render the pager according to the ListView.pager template and add listeners on it. * Set this.$pager with the produced jQuery element * @param {jQuery} [$node] a jQuery node where the rendered pager should be inserted * $node may be undefined, in which case the ListView inserts the pager into this.options.$pager * or into a div of its template */ render_pager: function($node) { if (!this.$pager && this.options.pager) { var self = this; this.$pager = $(QWeb.render("ListView.pager", {'widget': self})); this.$pager .on('click', 'a[data-pager-action]', function () { var $this = $(this); var max_page_index = Math.ceil(self.dataset.size() / self._limit) - 1; switch ($this.data('pager-action')) { case 'first': self.page = 0; break; case 'last': self.page = max_page_index; break; case 'next': self.page += 1; break; case 'previous': self.page -= 1; break; } if (self.page < 0) { self.page = max_page_index; } else if (self.page > max_page_index) { self.page = 0; } self.reload_content(); }).find('.oe_list_pager_state') .click(function (e) { e.stopPropagation(); var $this = $(this); var $select = $('').appendTo('body').focus().remove(); } }) .delegate('td.oe_list_field_cell button', 'click', function (e) { e.stopPropagation(); var $target = $(e.currentTarget), field = $target.closest('td').data('field'), $row = $target.closest('tr'), record_id = self.row_id($row); if ($target.attr('disabled')) { return; } $target.attr('disabled', 'disabled'); // note: $.data converts data to number if it's composed only // of digits, nice when storing actual numbers, not nice when // storing strings composed only of digits. Force the action // name to be a string $(self).trigger('action', [field.toString(), record_id, function (id) { $target.removeAttr('disabled'); return self.reload_record(self.records.get(id)); }]); }) .delegate('a', 'click', function (e) { e.stopPropagation(); }) .delegate('tr', 'click', function (e) { var row_id = self.row_id(e.currentTarget); if (row_id) { e.stopPropagation(); if (!self.dataset.select_id(row_id)) { throw new Error(_t("Could not find id in dataset")); } self.row_clicked(e); } }); }, row_clicked: function (e, view) { $(this).trigger( 'row_link', [this.dataset.ids[this.dataset.index], this.dataset, view]); }, render_cell: function (record, column) { var value; if(column.type === 'reference') { value = record.get(column.id); var ref_match; // Ensure that value is in a reference "shape", otherwise we're // going to loop on performing name_get after we've resolved (and // set) a human-readable version. m2o does not have this issue // because the non-human-readable is just a number, where the // human-readable version is a pair if (value && (ref_match = /^([\w\.]+),(\d+)$/.exec(value))) { // reference values are in the shape "$model,$id" (as a // string), we need to split and name_get this pair in order // to get a correctly displayable value in the field var model = ref_match[1], id = parseInt(ref_match[2], 10); new data.DataSet(this.view, model).name_get([id]).done(function(names) { if (!names.length) { return; } record.set(column.id + '__display', names[0][1]); }); } } else if (column.type === 'many2one') { value = record.get(column.id); // m2o values are usually name_get formatted, [Number, String] // pairs, but in some cases only the id is provided. In these // cases, we need to perform a name_get call to fetch the actual // displayable value if (typeof value === 'number' || value instanceof Number) { // fetch the name, set it on the record (in the right field) // and let the various registered events handle refreshing the // row new data.DataSet(this.view, column.relation) .name_get([value]).done(function (names) { if (!names.length) { return; } record.set(column.id, names[0]); }); } } else if (column.type === 'many2many') { value = record.get(column.id); // non-resolved (string) m2m values are arrays if (value instanceof Array && !_.isEmpty(value) && !record.get(column.id + '__display')) { var ids; // they come in two shapes: if (value[0] instanceof Array) { _.each(value, function(command) { switch (command[0]) { case 4: ids.push(command[1]); break; case 5: ids = []; break; case 6: ids = command[2]; break; default: throw new Error(_.str.sprintf( _t("Unknown m2m command %s"), command[0])); } }); } else { // 2. an array of ids ids = value; } new Model(column.relation) .call('name_get', [ids, this.dataset.get_context()]).done(function (names) { // FIXME: nth horrible hack in this poor listview record.set(column.id + '__display', _(names).pluck(1).join(', ')); record.set(column.id, ids); }); // temp empty value record.set(column.id, false); } } return column.format(record.toForm().data, { model: this.dataset.model, id: record.get('id') }); }, render: function () { var self = this; this.$current.html( QWeb.render('ListView.rows', _.extend({}, this, { render_cell: function () { return self.render_cell.apply(self, arguments); } }))); this.pad_table_to(4); }, pad_table_to: function (count) { if (this.records.length >= count || _(this.columns).any(function(column) { return column.meta; })) { return; } var cells = []; if (this.options.selectable) { cells.push(''); } _(this.columns).each(function(column) { if (column.invisible === '1') { return; } cells.push(' '); }); if (this.options.deletable) { cells.push(''); } cells.unshift(''); cells.push(''); var row = cells.join(''); this.$current .children('tr:not([data-id])').remove().end() .append(new Array(count - this.records.length + 1).join(row)); }, /** * Gets the ids of all currently selected records, if any * @returns {Object} object with the keys ``ids`` and ``records``, holding respectively the ids of all selected records and the records themselves. */ get_selection: function () { var result = {ids: [], records: []}; if (!this.options.selectable) { return result; } var records = this.records; this.$current.find('th.oe_list_record_selector input:checked') .closest('tr').each(function () { var record = records.get($(this).data('id')); result.ids.push(record.get('id')); result.records.push(record.attributes); }); return result; }, /** * Returns the identifier of the object displayed in the provided table * row * * @param {Object} row the selected table row * @returns {Number|String} the identifier of the row's object */ row_id: function (row) { return $(row).data('id'); }, /** * Death signal, cleans up list display */ on_records_reset: function () { _(this.record_callbacks).each(function (callback, event) { this.records.unbind(event, callback); }, this); if (!this.$current) { return; } this.$current.remove(); }, get_records: function () { return this.records.map(function (record) { return {count: 1, values: record.attributes}; }); }, /** * Reloads the provided record by re-reading its content from the server. * * @param {Record} record * @returns {$.Deferred} promise to the finalization of the reloading */ reload_record: function (record) { return this.view.reload_record(record); }, /** * Renders a list record to HTML * * @param {Record} record index of the record to render in ``this.rows`` * @returns {String} QWeb rendering of the selected record */ render_record: function (record) { var self = this; var index = this.records.indexOf(record); return QWeb.render('ListView.row', { columns: this.columns, options: this.options, record: record, row_parity: (index % 2 === 0) ? 'even' : 'odd', view: this.view, render_cell: function () { return self.render_cell.apply(self, arguments); } }); } }); ListView.Groups = Class.extend( /** @lends instance.web.ListView.Groups# */{ passthrough_events: 'action deleted row_link', /** * Grouped display for the ListView. Handles basic DOM events and interacts * with the :js:class:`~DataGroup` bound to it. * * Provides events similar to those of * :js:class:`~instance.web.ListView.List` * * @constructs instance.web.ListView.Groups * @extends instance.web.Class * * @param {instance.web.ListView} view * @param {Object} [options] * @param {Collection} [options.records] * @param {Object} [options.options] * @param {Array} [options.columns] */ init: function (view, options) { options = options || {}; this.view = view; this.records = options.records || view.records; this.options = options.options || view.options; this.columns = options.columns || view.columns; this.datagroup = null; this.$row = null; this.children = {}; this.page = 0; var self = this; this.records.bind('reset', function () { return self.on_records_reset(); }); }, make_fragment: function () { return document.createDocumentFragment(); }, /** * Returns a DOM node after which a new tbody can be inserted, so that it * follows the provided row. * * Necessary to insert the result of a new group or list view within an * existing groups render, without losing track of the groups's own * elements * * @param {HTMLTableRowElement} row the row after which the caller wants to insert a body * @returns {HTMLTableSectionElement} element after which a tbody can be inserted */ point_insertion: function (row) { var $row = $(row); var red_letter_tboday = $row.closest('tbody')[0]; var $next_siblings = $row.nextAll(); if ($next_siblings.length) { var $root_kanal = $('').insertAfter(red_letter_tboday); $root_kanal.append($next_siblings); this.elements.splice( _.indexOf(this.elements, red_letter_tboday), 0, $root_kanal[0]); } return red_letter_tboday; }, make_paginator: function () { var self = this; var $prev = $('') .click(function (e) { e.stopPropagation(); if (self.page > 0) { self.page -= 1; } else { self.page = self.max_page_index; } self.$row.closest('tbody').next() .replaceWith(self.render()); }); var $next = $('') .click(function (e) { e.stopPropagation(); if (self.page < self.max_page_index) { self.page += 1; } else { self.page = 0; } self.$row.closest('tbody').next() .replaceWith(self.render()); }); this.$row.children().last() .addClass('oe_list_group_pagination') .append($prev) .append('') .append($next); }, open: function (point_insertion) { this.render().insertAfter(point_insertion); var no_subgroups = _(this.datagroup.group_by).isEmpty(), records_terminated = !this.datagroup.context['group_by_no_leaf']; if (no_subgroups && records_terminated) { this.make_paginator(); } }, close: function () { this.$row.children().last().find('button').remove(); this.$row.children().last().find('span').remove(); this.records.reset(); }, /** * Prefixes ``$node`` with floated spaces in order to indent it relative * to its own left margin/baseline * * @param {jQuery} $node jQuery object to indent * @param {Number} level current nesting level, >= 1 * @returns {jQuery} the indentation node created */ indent: function ($node, level) { return $('') .css({'float': 'left', 'white-space': 'pre'}) .text(new Array(level).join(' ')) .prependTo($node); }, render_groups: function (datagroups) { var self = this; var placeholder = this.make_fragment(); _(datagroups).each(function (group) { if (self.children[group.value]) { self.records.proxy(group.value).reset(); delete self.children[group.value]; } var child = self.children[group.value] = new (self.view.options.GroupsType)(self.view, { records: self.records.proxy(group.value), options: self.options, columns: self.columns }); self.bind_child_events(child); child.datagroup = group; var $row = child.$row = $(''); if (group.openable && group.length) { $row.click(function (e) { if (!$row.data('open')) { $row.data('open', true) .find('span.ui-icon') .removeClass('ui-icon-triangle-1-e') .addClass('ui-icon-triangle-1-s'); child.open(self.point_insertion(e.currentTarget)); } else { $row.removeData('open') .find('span.ui-icon') .removeClass('ui-icon-triangle-1-s') .addClass('ui-icon-triangle-1-e'); child.close(); // force recompute the selection as closing group reset properties var selection = self.get_selection(); $(self).trigger('selected', [selection.ids, this.records]); } }); } placeholder.appendChild($row[0]); var $group_column = $('').appendTo($row); // Don't fill this if group_by_no_leaf but no group_by if (group.grouped_on) { var row_data = {}; row_data[group.grouped_on] = group; var group_label = _t("Undefined"); var group_column = _(self.columns).detect(function (column) { return column.id === group.grouped_on; }); if (group_column) { try { group_label = group_column.format(row_data, { value_if_empty: _t("Undefined"), process_modifiers: false }); } catch (e) { group_label = _.str.escapeHTML(row_data[group_column.id].value); } } else { group_label = group.value; if (group_label instanceof Array) { group_label = group_label[1]; } if (group_label === false) { group_label = _t('Undefined'); } group_label = _.str.escapeHTML(group_label); } // group_label is html-clean (through format or explicit // escaping if format failed), can inject straight into HTML $group_column.html(_.str.sprintf(_t("%s (%d)"), group_label, group.length)); if (group.length && group.openable) { // Make openable if not terminal group & group_by_no_leaf $group_column.prepend(''); } else { // Kinda-ugly hack: jquery-ui has no "empty" icon, so set // wonky background position to ensure nothing is displayed // there but the rest of the behavior is ui-icon's $group_column.prepend(''); } } self.indent($group_column, group.level); if (self.options.selectable) { $row.append(''); } _(self.columns).chain() .filter(function (column) { return column.invisible !== '1'; }) .each(function (column) { if (column.meta) { // do not do anything } else if (column.id in group.aggregates) { var r = {}; r[column.id] = {value: group.aggregates[column.id]}; $('') .html(column.format(r, {process_modifiers: false})) .appendTo($row); } else { $row.append(''); } }); if (self.options.deletable) { $row.append(''); } }); return placeholder; }, bind_child_events: function (child) { var $this = $(this), self = this; $(child).bind('selected', function (e, _0, _1, deselected) { // can have selections spanning multiple links var selection = self.get_selection(); $this.trigger(e, [selection.ids, selection.records, deselected]); }).bind(this.passthrough_events, function (e) { // additional positional parameters are provided to trigger as an // Array, following the event type or event object, but are // provided to the .bind event handler as *args. // Convert our *args back into an Array in order to trigger them // on the group itself, so it can ultimately be forwarded wherever // it's supposed to go. var args = Array.prototype.slice.call(arguments, 1); $this.trigger.call($this, e, args); }); }, render_dataset: function (dataset) { var self = this, list = new (this.view.options.ListType)(this, { options: this.options, columns: this.columns, dataset: dataset, records: this.records }); this.bind_child_events(list); var view = this.view, limit = view._limit, page = this.datagroup.openable ? this.page : view.page; var fields = _.pluck(_.select(this.columns, function(x) {return x.tag == "field";}), 'name'); var options = { offset: page * limit, limit: limit, context: {bin_size: true} }; //TODO xmo: investigate why we need to put the setTimeout return $.async_when().then(function() { return dataset.read_slice(fields, options).then(function (records) { // FIXME: ignominious hacks, parents (aka form view) should not send two ListView#reload_content concurrently if (self.records.length) { self.records.reset(null, {silent: true}); } if (!self.datagroup.openable) { view.configure_pager(dataset); } else { if (dataset.size() == records.length) { // only one page self.$row.find('td.oe_list_group_pagination').find('button').remove(); self.$row.find('td.oe_list_group_pagination').find('span').remove(); } else { self.max_page_index = Math.ceil(dataset.size() / limit) - 1; self.$row .find('.oe_list_pager_state') .text(_.str.sprintf(_t("%(page)d/%(page_count)d"), { page: page + 1, page_count: self.max_page_index + 1 })) .end() .find('button[data-pager-action=previous]') .toggleClass('disabled', page === 0) .end() .find('button[data-pager-action=next]') .toggleClass('disabled', page === self.max_page_index); } } self.records.add(records, {silent: true}); list.render(); if (_.isEmpty(records)) { view.no_result(); } return list; }); }); }, setup_resequence_rows: function (list, dataset) { // drag and drop enabled if list is not sorted (unless it is sorted by // sequence (ASC)), and there is a visible column with @widget=handle // or "sequence" column in the view. if ((dataset.sort && dataset.sort() && dataset.sort() !== 'sequence' && dataset.sort() !== 'sequence ASC') || !_(this.columns).any(function (column) { return column.widget === 'handle' || column.name === 'sequence'; })) { return; } var sequence_field = _(this.columns).find(function (c) { return c.widget === 'handle'; }); var seqname = sequence_field ? sequence_field.name : 'sequence'; // ondrop, move relevant record & fix sequences list.$current.sortable({ axis: 'y', items: '> tr[data-id]', helper: 'clone' }); if (sequence_field) { list.$current.sortable('option', 'handle', '.oe_list_field_handle'); } list.$current.sortable('option', { start: function (e, ui) { ui.placeholder.height(ui.item.height()); }, stop: function (event, ui) { var to_move = list.records.get(ui.item.data('id')), target_id = ui.item.prev().data('id'), from_index = list.records.indexOf(to_move), target = list.records.get(target_id); if (list.records.at(from_index - 1) == target) { return; } list.records.remove(to_move, {silent: true}); var to = target_id ? list.records.indexOf(target) + 1 : 0; list.records.add(to_move, { at: to, silent: true }); // resequencing time! var record, index = to, // if drag to 1st row (to = 0), start sequencing from 0 // (exclusive lower bound) seq = to ? list.records.at(to - 1).get(seqname) : 0; var defs = []; var fct = function (dataset, id, seq) { defs.push($.async_when().then(function () { var attrs = {}; attrs[seqname] = seq; return dataset.write(id, attrs, {internal_dataset_changed: true}); })); }; while (++seq, (record = list.records.at(index++))) { // write are independent from one another, so we can just // launch them all at the same time and we don't really // give a fig about when they're done fct(dataset, record.get('id'), seq); record.set(seqname, seq); } $.when.apply($, defs).then(function () { // use internal_dataset_changed and trigger one onchange after all writes dataset.trigger("dataset_changed"); }); } }); }, render: function (post_render) { var self = this; var $el = $(''); this.elements = [$el[0]]; this.datagroup.list( _(this.view.visible_columns).chain() .filter(function (column) { return column.tag === 'field';}) .pluck('name').value(), function (groups) { $el[0].appendChild( self.render_groups(groups)); if (post_render) { post_render(); } }, function (dataset) { self.render_dataset(dataset).then(function (list) { self.children[null] = list; self.elements = [list.$current.replaceAll($el)[0]]; self.setup_resequence_rows(list, dataset); }).always(function() { if (post_render) { post_render(); } self.view.trigger('view_list_rendered'); }); }); return $el; }, /** * Returns the ids of all selected records for this group, and the records * themselves */ get_selection: function () { var ids = [], records = []; _(this.children) .each(function (child) { var selection = child.get_selection(); ids.push.apply(ids, selection.ids); records.push.apply(records, selection.records); }); return {ids: ids, records: records}; }, on_records_reset: function () { this.children = {}; $(this.elements).remove(); }, get_records: function () { if (_(this.children).isEmpty()) { if (!this.datagroup.length) { return; } return { count: this.datagroup.length, values: this.datagroup.aggregates }; } return _(this.children).chain() .map(function (child) { return child.get_records(); }).flatten().value(); } }); /** * Serializes concurrent calls to this asynchronous method. The method must * return a deferred or promise. * * Current-implementation is class-serialized (the mutex is common to all * instances of the list view). Can be switched to instance-serialized if * having concurrent list views becomes possible and common. */ function synchronized(fn) { var fn_mutex = new utils.Mutex(); return function () { var obj = this; var args = _.toArray(arguments); return fn_mutex.exec(function () { if (obj.isDestroyed()) { return $.when(); } return fn.apply(obj, args); }); }; } var DataGroup = Class.extend({ init: function(parent, model, domain, context, group_by, level) { this.model = new Model(model, context, domain); this.group_by = group_by; this.context = context; this.domain = domain; this.level = level || 0; }, list: function (fields, ifGroups, ifRecords) { var self = this; if (!_.isEmpty(this.group_by)) { // ensure group_by fields are read. fields = _.unique((fields || []).concat(this.group_by)); } var query = this.model.query(fields).order_by(this.sort).group_by(this.group_by); $.when(query).done(function (querygroups) { // leaf node if (!querygroups) { var ds = new data.DataSetSearch(self, self.model.name, self.model.context(), self.model.domain()); ds._sort = self.sort; ifRecords(ds); return; } // internal node var child_datagroups = _(querygroups).map(function (group) { var child_context = _.extend( {}, self.model.context(), group.model.context()); var child_dg = new DataGroup( self, self.model.name, group.model.domain(), child_context, group.model._context.group_by, self.level + 1); child_dg.sort = self.sort; // copy querygroup properties child_dg.__context = child_context; child_dg.__domain = group.model.domain(); child_dg.folded = group.get('folded'); child_dg.grouped_on = group.get('grouped_on'); child_dg.length = group.get('length'); child_dg.value = group.get('value'); child_dg.openable = group.get('has_children'); child_dg.aggregates = group.get('aggregates'); return child_dg; }); ifGroups(child_datagroups); }); } }); var StaticDataGroup = DataGroup.extend({ init: function (dataset) { this.dataset = dataset; }, list: function (fields, ifGroups, ifRecords) { ifRecords(this.dataset); } }); var Column = Class.extend({ init: function (id, tag, attrs) { _.extend(attrs, { id: id, tag: tag }); this.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {}; delete attrs.modifiers; _.extend(this, attrs); if (this.modifiers['tree_invisible']) { this.invisible = '1'; } else { delete this.invisible; } }, modifiers_for: function (fields) { var out = {}; var domain_computer = data.compute_domain; for (var attr in this.modifiers) { if (!this.modifiers.hasOwnProperty(attr)) { continue; } var modifier = this.modifiers[attr]; out[attr] = _.isBoolean(modifier) ? modifier : domain_computer(modifier, fields); } return out; }, to_aggregate: function () { if (this.type !== 'integer' && this.type !== 'float' && this.type !== 'monetary') { return {}; } var aggregation_func = this['group_operator'] || 'sum'; if (!(aggregation_func in this)) { return {}; } var C = function (fn, label) { this['function'] = fn; this.label = label; }; C.prototype = this; return new C(aggregation_func, this[aggregation_func]); }, /** * * @param row_data record whose values should be displayed in the cell * @param {Object} [options] * @param {String} [options.value_if_empty=''] what to display if the field's value is ``false`` * @param {Boolean} [options.process_modifiers=true] should the modifiers be computed ? * @param {String} [options.model] current record's model * @param {Number} [options.id] current record's id * @return {String} */ format: function (row_data, options) { options = options || {}; var attrs = {}; if (options.process_modifiers !== false) { attrs = this.modifiers_for(row_data); } if (attrs.invisible) { return ''; } if (!row_data[this.id]) { return options.value_if_empty === undefined ? '' : options.value_if_empty; } return this._format(row_data, options); }, /** * Method to override in order to provide alternative HTML content for the * cell. Column._format will simply call ``instance.web.format_value`` and * escape the output. * * The output of ``_format`` will *not* be escaped by ``format``, any * escaping *must be done* by ``format``. * * @private */ _format: function (row_data, options) { return _.escape(formats.format_value( row_data[this.id].value, this, options.value_if_empty)); } }); var MetaColumn = Column.extend({ meta: true, init: function (id, string) { this._super(id, '', {string: string}); } }); // to do: do this in a better way (communicate with view_list_editable) ListView.MetaColumn = MetaColumn; var ColumnButton = Column.extend({ /** * Return an actual ``