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 = $('', row_data[this.id].value ? 'checked="checked"' : ''); } }); var ColumnBinary = Column.extend({ /** * Return a link to the binary data as a file * * @private */ _format: function (row_data, options) { var text = _t("Download"), filename=_t('Binary file'); var value = row_data[this.id].value; if (!value) { return options.value_if_empty || ''; } var download_url; if (value.substr(0, 10).indexOf(' ') == -1) { download_url = "data:application/octet-stream;base64," + value; } else { download_url = session.url('/web/content', {model: options.model, field: this.id, id: options.id, download: true}); if (this.filename) { download_url += '&filename_field=' + this.filename; } } if (this.filename && row_data[this.filename]) { text = _.str.sprintf(_t("Download \"%s\""), formats.format_value( row_data[this.filename].value, {type: 'char'})); filename = row_data[this.filename].value; } return _.template('<%-text%> (<%-size%>)')({ text: text, href: download_url, size: utils.binary_to_binsize(value), download: filename, }); } }); var ColumnChar = Column.extend({ replacement: '*', /** * If password field, only display replacement characters (if value is * non-empty) */ _format: function (row_data, options) { var value = row_data[this.id].value; if (value && this.password === 'True') { return value.replace(/[\s\S]/g, _.escape(this.replacement)); } return this._super(row_data, options); } }); var ColumnProgressBar = Column.extend({ /** * Return a formatted progress bar display * * @private */ _format: function (row_data, options) { return _.template( '<%-value%>%')({ value: _.str.sprintf("%.0f", row_data[this.id].value || 0) }); } }); var ColumnHandle = Column.extend({ init: function () { this._super.apply(this, arguments); // Handle overrides the field to not be form-editable. this.modifiers.readonly = true; }, /** * Return styling hooks for a drag handle * * @private */ _format: function (row_data, options) { return ''; } }); var ColumnMany2OneButton = Column.extend({ _format: function (row_data, options) { this.has_value = !!row_data[this.id].value; this.icon = this.has_value ? 'gtk-yes' : 'gtk-no'; this.string = this.has_value ? _t('View') : _t('Create'); return QWeb.render('Many2OneButton.cell', { 'widget': this, 'prefix': session.prefix, }); }, }); var ColumnMany2Many = Column.extend({ _format: function (row_data, options) { if (!_.isEmpty(row_data[this.id].value)) { // If value, use __display version for printing row_data[this.id] = row_data[this.id + '__display']; } return this._super(row_data, options); } }); var ColumnReference = Column.extend({ _format: function (row_data, options) { if (!_.isEmpty(row_data[this.id].value)) { // If value, use __display version for printing if (!!row_data[this.id + '__display']) { row_data[this.id] = row_data[this.id + '__display']; } else { row_data[this.id] = {'value': ''}; } } return this._super(row_data, options); } }); var ColumnUrl = Column.extend({ /** * Regex checking if a URL has a scheme */ PROTOCOL_REGEX: /^(?!\w+:?\/\/)/, /** * Format a column as a URL if the column has content. * Add "//" (inherit current protocol) specified in * RFC 1808, 2396, and 3986 if no other protocol is included. * * @param row_data record whose values should be displayed in the cell * @param options */ _format: function(row_data, options) { var value = row_data[this.id].value; if (value) { return _.template("<%-text%>")({ href: value.trim().replace(this.PROTOCOL_REGEX, '//'), text: value }); } return this._super(row_data, options); } }); var ColumnMonetary = Column.extend({ _format: function (row_data, options) { //name of currency field is defined either by field attribute, in view options or we assume it is named currency_id var currency_field = (this.options && this.options.currency_field) || this.currency_field || 'currency_id'; var currency_id = row_data[currency_field] && row_data[currency_field].value[0]; var currency = session.get_currency(currency_id); var digits_precision = this.digits || (currency && currency.digits); var value = formats.format_value(row_data[this.id].value || 0, {type: this.type, digits: digits_precision}, options.value_if_empty); if (currency) { if (currency.position === "after") { value += ' ' + currency.symbol; } else { value = currency.symbol + ' ' + value; } } return value; }, }); var ColumnToggleButton = Column.extend({ format: function (row_data, options) { this._super(row_data, options); var button_tips = JSON.parse(this.options); var fieldname = this.field_name; var has_value = row_data[fieldname] && !!row_data[fieldname].value; this.icon = has_value ? 'gtk-yes' : 'gtk-normal'; this.string = has_value ? _t(button_tips ? button_tips['active']: ''): _t(button_tips ? button_tips['inactive']: ''); return QWeb.render('toggle_button', { widget: this, prefix: session.prefix, }); }, }); /** * Registry for column objects used to format table cells (and some other tasks * e.g. aggregation computations). * * Maps a field or button to a Column type via its ``$tag.$widget``, * ``$tag.$type`` or its ``$tag`` (alone). * * This specific registry has a dedicated utility method ``for_`` taking a * field (from fields_get/fields_view_get.field) and a node (from a view) and * returning the right object *already instantiated from the data provided*. * * @type {instance.web.Registry} */ list_widget_registry .add('field', Column) .add('field.boolean', ColumnBoolean) .add('field.binary', ColumnBinary) .add('field.char', ColumnChar) .add('field.progressbar', ColumnProgressBar) .add('field.handle', ColumnHandle) .add('button', ColumnButton) .add('field.many2onebutton', ColumnMany2OneButton) .add('field.reference', ColumnReference) .add('field.many2many', ColumnMany2Many) .add('field.url', ColumnUrl) .add('button.toggle_button', ColumnToggleButton) .add('field.monetary', ColumnMonetary); function for_ (id, field, node) { var description = _.extend({tag: node.tag}, field, node.attrs); var tag = description.tag; var Type = list_widget_registry.get_any([ tag + '.' + description.widget, tag + '.'+ description.type, tag ]); return new Type(id, node.tag, description); } return ListView; });