odoo.define('web.ListRenderer', function (require) { "use strict"; var BasicRenderer = require('web.BasicRenderer'); const { ComponentWrapper } = require('web.OwlCompatibility'); var config = require('web.config'); var core = require('web.core'); var dom = require('web.dom'); var field_utils = require('web.field_utils'); var Pager = require('web.Pager'); var utils = require('web.utils'); var viewUtils = require('web.viewUtils'); var _t = core._t; // Allowed decoration on the list's rows: bold, italic and bootstrap semantics classes var DECORATIONS = [ 'decoration-bf', 'decoration-it', 'decoration-danger', 'decoration-info', 'decoration-muted', 'decoration-primary', 'decoration-success', 'decoration-warning' ]; var FIELD_CLASSES = { char: 'o_list_char', float: 'o_list_number', integer: 'o_list_number', monetary: 'o_list_number', text: 'o_list_text', many2one: 'o_list_many2one', }; var ListRenderer = BasicRenderer.extend({ className: 'o_list_view', events: { "mousedown": "_onMouseDown", "click .o_optional_columns_dropdown .dropdown-item": "_onToggleOptionalColumn", "click .o_optional_columns_dropdown_toggle": "_onToggleOptionalColumnDropdown", 'click tbody tr': '_onRowClicked', 'change tbody .o_list_record_selector': '_onSelectRecord', 'click thead th.o_column_sortable': '_onSortColumn', 'click .o_list_record_selector': '_onToggleCheckbox', 'click .o_group_header': '_onToggleGroup', 'change thead .o_list_record_selector input': '_onToggleSelection', 'keypress thead tr td': '_onKeyPress', 'keydown td': '_onKeyDown', 'keydown th': '_onKeyDown', }, sampleDataTargets: [ '.o_data_row', '.o_group_header', '.o_list_table > tfoot', '.o_list_table > thead .o_list_record_selector', ], /** * @constructor * @param {Widget} parent * @param {any} state * @param {Object} params * @param {boolean} params.hasSelectors */ init: function (parent, state, params) { this._super.apply(this, arguments); this._preprocessColumns(); this.columnInvisibleFields = params.columnInvisibleFields || {}; this.rowDecorations = this._extractDecorationAttrs(this.arch); this.fieldDecorations = {}; for (const field of this.arch.children.filter(c => c.tag === 'field')) { const decorations = this._extractDecorationAttrs(field); this.fieldDecorations[field.attrs.name] = decorations; } this.hasSelectors = params.hasSelectors; this.selection = params.selectedRecords || []; this.pagers = []; // instantiated pagers (only for grouped lists) this.isGrouped = this.state.groupedBy.length > 0; this.groupbys = params.groupbys; }, /** * Compute columns visilibity. This can't be done earlier as we need the * controller to respond to the load_optional_fields call of processColumns. * * @override */ willStart: function () { this._processColumns(this.columnInvisibleFields); return this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Order to focus to be given to the content of the current view * * @override */ giveFocus: function () { this.$('th:eq(0) input, th:eq(1)').first().focus(); }, /** * @override */ updateState: function (state, params) { this._setState(state); this.isGrouped = this.state.groupedBy.length > 0; this.columnInvisibleFields = params.columnInvisibleFields || {}; this._processColumns(this.columnInvisibleFields); if (params.selectedRecords) { this.selection = params.selectedRecords; } return this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * This method does a in-memory computation of the aggregate values, for * each columns that corresponds to a numeric field with a proper aggregate * function. * * The result of these computations is stored in the 'aggregate' key of each * column of this.columns. This will be then used by the _renderFooter * method to display the appropriate amount. * * @private */ _computeAggregates: function () { var self = this; var data = []; if (this.selection.length) { utils.traverse_records(this.state, function (record) { if (_.contains(self.selection, record.id)) { data.push(record); // find selected records } }); } else { data = this.state.data; } _.each(this.columns, this._computeColumnAggregates.bind(this, data)); }, /** * Compute the aggregate values for a given column and a set of records. * The aggregate values are then written, if applicable, in the 'aggregate' * key of the column object. * * @private * @param {Object[]} data a list of selected/all records * @param {Object} column */ _computeColumnAggregates: function (data, column) { var attrs = column.attrs; var field = this.state.fields[attrs.name]; if (!field) { return; } var type = field.type; if (type !== 'integer' && type !== 'float' && type !== 'monetary') { return; } var func = (attrs.sum && 'sum') || (attrs.avg && 'avg') || (attrs.max && 'max') || (attrs.min && 'min'); if (func) { var count = 0; var aggregateValue = 0; if (func === 'max') { aggregateValue = -Infinity; } else if (func === 'min') { aggregateValue = Infinity; } _.each(data, function (d) { count += 1; var value = (d.type === 'record') ? d.data[attrs.name] : d.aggregateValues[attrs.name]; if (func === 'avg') { aggregateValue += value; } else if (func === 'sum') { aggregateValue += value; } else if (func === 'max') { aggregateValue = Math.max(aggregateValue, value); } else if (func === 'min') { aggregateValue = Math.min(aggregateValue, value); } }); if (func === 'avg') { aggregateValue = count ? aggregateValue / count : aggregateValue; } column.aggregate = { help: attrs[func], value: aggregateValue, }; } }, /** * Extract the decoration attributes (e.g. decoration-danger) of a node. The * condition is processed such that it is ready to be evaluated. * * @private * @param {Object} node the or a node * @returns {Object} */ _extractDecorationAttrs: function (node) { const decorations = {}; for (const [key, expr] of Object.entries(node.attrs)) { if (DECORATIONS.includes(key)) { const cssClass = key.replace('decoration', 'text'); decorations[cssClass] = py.parse(py.tokenize(expr)); } } return decorations; }, /** * * @private * @param {jQuery} $cell * @param {string} direction * @param {integer} colIndex * @returns {jQuery|null} */ _findConnectedCell: function ($cell, direction, colIndex) { var $connectedRow = $cell.closest('tr')[direction]('tr'); if (!$connectedRow.length) { // Is there another group ? Look at our parent's sibling // We can have th in tbody so we can't simply look for thead // if cell is a th and tbody instead var tbody = $cell.closest('tbody, thead'); var $connectedGroup = tbody[direction]('tbody, thead'); if ($connectedGroup.length) { // Found another group var $connectedRows = $connectedGroup.find('tr'); var rowIndex; if (direction === 'prev') { rowIndex = $connectedRows.length - 1; } else { rowIndex = 0; } $connectedRow = $connectedRows.eq(rowIndex); } else { // End of the table return; } } var $connectedCell; if ($connectedRow.hasClass('o_group_header')) { $connectedCell = $connectedRow.children(); this.currentColIndex = colIndex; } else if ($connectedRow.has('td.o_group_field_row_add').length) { $connectedCell = $connectedRow.find('.o_group_field_row_add'); this.currentColIndex = colIndex; } else { var connectedRowChildren = $connectedRow.children(); if (colIndex === -1) { colIndex = connectedRowChildren.length - 1; } $connectedCell = connectedRowChildren.eq(colIndex); } return $connectedCell; }, /** * return the number of visible columns. Note that this number depends on * the state of the renderer. For example, in editable mode, it could be * one more that in non editable mode, because there may be a visible 'trash * icon'. * * @private * @returns {integer} */ _getNumberOfCols: function () { var n = this.columns.length; return this.hasSelectors ? n + 1 : n; }, /** * Returns the local storage key for stored enabled optional columns * * @private * @returns {string} */ _getOptionalColumnsStorageKeyParts: function () { var self = this; return { fields: _.map(this.state.fieldsInfo[this.viewType], function (_, fieldName) { return {name: fieldName, type: self.state.fields[fieldName].type}; }), }; }, /** * Adjacent buttons (in the arch) are displayed in a single column. This * function iterates over the arch's nodes and replaces "button" nodes by * "button_group" nodes, with a single "button_group" node for adjacent * "button" nodes. A "button_group" node has a "children" attribute * containing all "button" nodes in the group. * * @private */ _groupAdjacentButtons: function () { const children = []; let groupId = 0; let buttonGroupNode = null; for (const c of this.arch.children) { if (c.tag === 'button') { if (!buttonGroupNode) { buttonGroupNode = { tag: 'button_group', children: [c], attrs: { name: `button_group_${groupId++}`, modifiers: {}, }, }; children.push(buttonGroupNode); } else { buttonGroupNode.children.push(c); } } else { buttonGroupNode = null; children.push(c); } } this.arch.children = children; }, /** * Processes arch's child nodes for the needs of the list view: * - detects oe_read_only/oe_edit_only classnames * - groups adjacent buttons in a single column. * This function is executed only once, at initialization. * * @private */ _preprocessColumns: function () { this._processModeClassNames(); this._groupAdjacentButtons(); // set as readOnly (resp. editOnly) button groups containing only // readOnly (resp. editOnly) buttons, s.t. no column is rendered this.arch.children.filter(c => c.tag === 'button_group').forEach(c => { c.attrs.editOnly = c.children.every(n => n.attrs.editOnly); c.attrs.readOnly = c.children.every(n => n.attrs.readOnly); }); }, /** * Removes the columns which should be invisible. This function is executed * at each (re-)rendering of the list. * * @param {Object} columnInvisibleFields contains the column invisible modifier values */ _processColumns: function (columnInvisibleFields) { var self = this; this.handleField = null; this.columns = []; this.optionalColumns = []; this.optionalColumnsEnabled = []; var storedOptionalColumns; this.trigger_up('load_optional_fields', { keyParts: this._getOptionalColumnsStorageKeyParts(), callback: function (res) { storedOptionalColumns = res; }, }); _.each(this.arch.children, function (c) { if (c.tag !== 'control' && c.tag !== 'groupby' && c.tag !== 'header') { var reject = c.attrs.modifiers.column_invisible; // If there is an evaluated domain for the field we override the node // attribute to have the evaluated modifier value. if (c.tag === "button_group") { // FIXME: 'column_invisible' attribute is available for fields *and* buttons, // so 'columnInvisibleFields' variable name is misleading, it should be renamed reject = c.children.every(child => columnInvisibleFields[child.attrs.name]); } else if (c.attrs.name in columnInvisibleFields) { reject = columnInvisibleFields[c.attrs.name]; } if (!reject && c.attrs.widget === 'handle') { self.handleField = c.attrs.name; if (self.isGrouped) { reject = true; } } if (!reject && c.attrs.optional) { self.optionalColumns.push(c); var enabled; if (storedOptionalColumns === undefined) { enabled = c.attrs.optional === 'show'; } else { enabled = _.contains(storedOptionalColumns, c.attrs.name); } if (enabled) { self.optionalColumnsEnabled.push(c.attrs.name); } reject = !enabled; } if (!reject) { self.columns.push(c); } } }); }, /** * Classnames "oe_edit_only" and "oe_read_only" aim to only display the cell * in the corresponding mode. This only concerns lists inside form views * (for x2many fields). This function detects the className and stores a * flag on the node's attrs accordingly, to ease further computations. * * @private */ _processModeClassNames: function () { this.arch.children.forEach(c => { if (c.attrs.class) { c.attrs.editOnly = /\boe_edit_only\b/.test(c.attrs.class); c.attrs.readOnly = /\boe_read_only\b/.test(c.attrs.class); } }); }, /** * Render a list of , with aggregates if available. It can be displayed * in the footer, or for each open groups. * * @private * @param {any} aggregateValues * @returns {jQueryElement[]} a list of with the aggregate values */ _renderAggregateCells: function (aggregateValues) { var self = this; return _.map(this.columns, function (column) { var $cell = $(''); if (config.isDebug()) { $cell.addClass(column.attrs.name); } if (column.attrs.editOnly) { $cell.addClass('oe_edit_only'); } if (column.attrs.readOnly) { $cell.addClass('oe_read_only'); } if (column.attrs.name in aggregateValues) { var field = self.state.fields[column.attrs.name]; var value = aggregateValues[column.attrs.name].value; var help = aggregateValues[column.attrs.name].help; var formatFunc = field_utils.format[column.attrs.widget]; if (!formatFunc) { formatFunc = field_utils.format[field.type]; } var formattedValue = formatFunc(value, field, { escape: true, digits: column.attrs.digits ? JSON.parse(column.attrs.digits) : undefined, }); $cell.addClass('o_list_number').attr('title', help).html(formattedValue); } return $cell; }); }, /** * Render the main body of the table, with all its content. Note that it * has been decided to always render at least 4 rows, even if we have less * data. The reason is that lists with 0 or 1 lines don't really look like * a table. * * @private * @returns {jQueryElement} a jquery element */ _renderBody: function () { var self = this; var $rows = this._renderRows(); while ($rows.length < 4) { $rows.push(self._renderEmptyRow()); } return $('').append($rows); }, /** * Render a cell for the table. For most cells, we only want to display the * formatted value, with some appropriate css class. However, when the * node was explicitely defined with a 'widget' attribute, then we * instantiate the corresponding widget. * * @private * @param {Object} record * @param {Object} node * @param {integer} colIndex * @param {Object} [options] * @param {Object} [options.mode] * @param {Object} [options.renderInvisible=false] * force the rendering of invisible cell content * @param {Object} [options.renderWidgets=false] * force the rendering of the cell value thanks to a widget * @returns {jQueryElement} a element */ _renderBodyCell: function (record, node, colIndex, options) { var tdClassName = 'o_data_cell'; if (node.tag === 'button_group') { tdClassName += ' o_list_button'; } else if (node.tag === 'field') { tdClassName += ' o_field_cell'; var typeClass = FIELD_CLASSES[this.state.fields[node.attrs.name].type]; if (typeClass) { tdClassName += (' ' + typeClass); } if (node.attrs.widget) { tdClassName += (' o_' + node.attrs.widget + '_cell'); } } if (node.attrs.editOnly) { tdClassName += ' oe_edit_only'; } if (node.attrs.readOnly) { tdClassName += ' oe_read_only'; } var $td = $('', { class: tdClassName, tabindex: -1 }); // We register modifiers on the element so that it gets the correct // modifiers classes (for styling) var modifiers = this._registerModifiers(node, record, $td, _.pick(options, 'mode')); // If the invisible modifiers is true, the element is left empty. // Indeed, if the modifiers was to change the whole cell would be // rerendered anyway. if (modifiers.invisible && !(options && options.renderInvisible)) { return $td; } if (node.tag === 'button_group') { for (const buttonNode of node.children) { if (!this.columnInvisibleFields[buttonNode.attrs.name]) { $td.append(this._renderButton(record, buttonNode)); } } return $td; } else if (node.tag === 'widget') { return $td.append(this._renderWidget(record, node)); } if (node.attrs.widget || (options && options.renderWidgets)) { var $el = this._renderFieldWidget(node, record, _.pick(options, 'mode')); return $td.append($el); } this._handleAttributes($td, node); this._setDecorationClasses($td, this.fieldDecorations[node.attrs.name], record); var name = node.attrs.name; var field = this.state.fields[name]; var value = record.data[name]; var formatter = field_utils.format[field.type]; var formatOptions = { escape: true, data: record.data, isPassword: 'password' in node.attrs, digits: node.attrs.digits && JSON.parse(node.attrs.digits), }; var formattedValue = formatter(value, field, formatOptions); var title = ''; if (field.type !== 'boolean') { title = formatter(value, field, _.extend(formatOptions, {escape: false})); } return $td.html(formattedValue).attr('title', title); }, /** * Renders the button element associated to the given node and record. * * @private * @param {Object} record * @param {Object} node * @returns {jQuery} a