odoo.define('web.EditableListRenderer', function (require) { "use strict"; /** * Editable List renderer * * The list renderer is reasonably complex, so we split it in two files. This * file simply 'includes' the basic ListRenderer to add all the necessary * behaviors to enable editing records. * * Unlike Odoo v10 and before, this list renderer is independant from the form * view. It uses the same widgets, but the code is totally stand alone. */ var core = require('web.core'); var dom = require('web.dom'); var ListRenderer = require('web.ListRenderer'); var utils = require('web.utils'); const { WidgetAdapterMixin } = require('web.OwlCompatibility'); var _t = core._t; ListRenderer.include({ RESIZE_DELAY: 200, custom_events: _.extend({}, ListRenderer.prototype.custom_events, { navigation_move: '_onNavigationMove', }), events: _.extend({}, ListRenderer.prototype.events, { 'click .o_field_x2many_list_row_add a': '_onAddRecord', 'click .o_group_field_row_add a': '_onAddRecordToGroup', 'keydown .o_field_x2many_list_row_add a': '_onKeyDownAddRecord', 'click tbody td.o_data_cell': '_onCellClick', 'click tbody tr:not(.o_data_row)': '_onEmptyRowClick', 'click tfoot': '_onFooterClick', 'click tr .o_list_record_remove': '_onRemoveIconClick', }), /** * @override * @param {Object} params * @param {boolean} params.addCreateLine * @param {boolean} params.addCreateLineInGroups * @param {boolean} params.addTrashIcon * @param {boolean} params.isMany2Many * @param {boolean} params.isMultiEditable */ init: function (parent, state, params) { this._super.apply(this, arguments); this.editable = params.editable; this.isMultiEditable = params.isMultiEditable; this.columnWidths = false; // if addCreateLine (resp. addCreateLineInGroups) is true, the renderer // will add a 'Add a line' link at the bottom of the list view (resp. // at the bottom of each group) this.addCreateLine = params.addCreateLine; this.addCreateLineInGroups = params.addCreateLineInGroups; // Controls allow overriding "add a line" by custom controls. // Each (only one is actually needed) is a container for (multiple) . // Each will be a "add a line" button with custom text and context. // The following code will browse the arch to find // all the that are inside this.creates = []; this.arch.children.forEach(child => { if (child.tag !== 'control') { return; } child.children.forEach(child => { if (child.tag !== 'create' || child.attrs.invisible) { return; } this.creates.push({ context: child.attrs.context, string: child.attrs.string, }); }); }); // Add the default button if we didn't find any custom button. if (this.creates.length === 0) { this.creates.push({ string: _t("Add a line"), }); } // if addTrashIcon is true, there will be a small trash icon at the end // of each line, so the user can delete a record. this.addTrashIcon = params.addTrashIcon; // replace the trash icon by X in case of many2many relations // so that it means 'unlink' instead of 'remove' this.isMany2Many = params.isMany2Many; this.currentRow = null; this.currentFieldIndex = null; this.isResizing = false; this.eventListeners = []; }, /** * @override * @returns {Promise} */ start: function () { core.bus.on('click', this, this._onWindowClicked.bind(this)); core.bus.on('resize', this, _.debounce(this._onResize.bind(this), this.RESIZE_DELAY)); core.bus.on('DOM_updated', this, () => this._freezeColumnWidths()); return this._super(); }, /** * Overriden to unbind all attached listeners * * @override */ destroy: function () { this.eventListeners.forEach(listener => { const { type, el, callback, options } = listener; el.removeEventListener(type, callback, options); }); return this._super.apply(this, arguments); }, /** * The list renderer needs to know if it is in the DOM, and to be notified * when it is attached to the DOM to properly compute column widths. * * @override */ on_attach_callback: function () { this.isInDOM = true; this._super(); // _freezeColumnWidths requests style information, which produces a // repaint, so we call it after _super to prevent flickering (in case // other code would also modify the DOM post rendering/before repaint) this._freezeColumnWidths(); }, /** * The list renderer needs to know if it is in the DOM to properly compute * column widths. * * @override */ on_detach_callback: function () { this.isInDOM = false; this._super(); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * If the given recordID is the list main one (or that no recordID is * given), then the whole view can be saved if one of the two following * conditions is true: * - There is no line in edition (all lines are saved so they are all valid) * - The line in edition can be saved * * If the given recordID is a record in the list, toggle a className on its * row's cells for invalid fields, so that we can style those cells * differently. * * @override * @param {string} [recordID] * @returns {string[]} */ canBeSaved: function (recordID) { if ((recordID || this.state.id) === this.state.id) { recordID = this.getEditableRecordID(); if (recordID === null) { return []; } } var fieldNames = this._super(recordID); this.$('.o_selected_row .o_data_cell').removeClass('o_invalid_cell'); this.$('.o_selected_row .o_data_cell:has(> .o_field_invalid)').addClass('o_invalid_cell'); return fieldNames; }, /** * We need to override the confirmChange method from BasicRenderer to * reevaluate the row decorations. Since they depends on the current value * of the row, they might have changed between each edit. * * @override */ confirmChange: function (state, recordID) { var self = this; return this._super.apply(this, arguments).then(function (widgets) { if (widgets.length) { var $row = self._getRow(recordID); var record = self._getRecord(recordID); self._setDecorationClasses($row, self.rowDecorations, record); self._updateFooter(); } return widgets; }); }, /** * This is a specialized version of confirmChange, meant to be called when * the change may have affected more than one line (so, for example, an * onchange which add/remove a few lines in a x2many. This does not occur * in a normal list view). * * The update is more difficult when other rows could have been changed. We * need to potentially remove some lines, add some other lines, update some * other lines and maybe reorder a few of them. This problem would neatly * be solved by using a virtual dom, but we do not have this luxury yet. * So, in the meantime, what we do is basically remove every current row * except the 'main' one (the row which caused the update), then rerender * every new row and add them before/after the main one. * * Note that this function assumes that the list isn't grouped, which is * fine as it's never the case for x2many lists. * * @param {Object} state * @param {string} id * @param {string[]} fields * @param {OdooEvent} ev * @returns {Promise} resolved with the list of widgets * that have been reset */ confirmUpdate: function (state, id, fields, ev) { var self = this; var oldData = this.state.data; this._setState(state); return this.confirmChange(state, id, fields, ev).then(function () { // If no record with 'id' can be found in the state, the // confirmChange method will have rerendered the whole view already, // so no further work is necessary. var record = self._getRecord(id); if (!record) { return; } _.each(oldData, function (rec) { if (rec.id !== id) { self._destroyFieldWidgets(rec.id); } }); // re-render whole body (outside the dom) self.defs = []; var $newBody = self._renderBody(); var defs = self.defs; delete self.defs; return Promise.all(defs).then(function () { // update registered modifiers to edit 'mode' because the call to // _renderBody set baseModeByRecord as 'readonly' _.each(self.columns, function (node) { self._registerModifiers(node, record, null, {mode: 'edit'}); }); // store the selection range to restore it once the table will // be re-rendered, and the current cell re-selected var currentRowID; var currentWidget; var focusedElement; var selectionRange; if (self.currentRow !== null) { currentRowID = self._getRecordID(self.currentRow); currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex]; if (currentWidget) { focusedElement = currentWidget.getFocusableElement().get(0); if (currentWidget.formatType !== 'boolean' && focusedElement) { selectionRange = dom.getSelectionRange(focusedElement); } } } // remove all data rows except the one being edited, and insert // data rows of the re-rendered body before and after it var $editedRow = self._getRow(id); $editedRow.nextAll('.o_data_row').remove(); $editedRow.prevAll('.o_data_row').remove(); var $newRow = $newBody.find('.o_data_row[data-id="' + id + '"]'); $newRow.prevAll('.o_data_row').get().reverse().forEach(function (row) { $(row).insertBefore($editedRow); }); $newRow.nextAll('.o_data_row').get().reverse().forEach(function (row) { $(row).insertAfter($editedRow); }); if (self.currentRow !== null) { var newRowIndex = $editedRow.prop('rowIndex') - 1; self.currentRow = newRowIndex; return self._selectCell(newRowIndex, self.currentFieldIndex, {force: true}) .then(function () { // restore the selection range currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex]; if (currentWidget) { focusedElement = currentWidget.getFocusableElement().get(0); if (selectionRange) { dom.setSelectionRange(focusedElement, selectionRange); } } }); } }); }); }, /** * Edit the first record in the list */ editFirstRecord: function (ev) { const $borderRow = this._getBorderRow(ev.data.side || 'first'); this._selectCell($borderRow.prop('rowIndex') - 1, ev.data.cellIndex || 0); }, /** * Edit a given record in the list * * @param {string} recordID */ editRecord: function (recordID) { var $row = this._getRow(recordID); var rowIndex = $row.prop('rowIndex') - 1; this._selectCell(rowIndex, 0); }, /** * Gives focus to a specific cell, given its row and its related column. * * @param {string} recordId * @param {Object} column */ focusCell: function (recordId, column) { var $row = this._getRow(recordId); var cellIndex = this.columns.indexOf(column); $row.find('.o_data_cell')[cellIndex].focus(); }, /** * Returns the recordID associated to the line which is currently in edition * or null if there is no line in edition. * * @returns {string|null} */ getEditableRecordID: function () { if (this.currentRow !== null) { return this._getRecordID(this.currentRow); } return null; }, /** * Returns whether the list is in multiple record edition from a given record. * * @private * @param {string} recordId * @returns {boolean} */ isInMultipleRecordEdition: function (recordId) { return this.isEditable() && this.isMultiEditable && this.selection.includes(recordId); }, /** * Returns whether the list can be edited. * It's true when: * - the list `editable` property is set, * - or at least one record is selected (becomes partially editable) * * @returns {boolean} */ isEditable: function () { return this.editable || (this.isMultiEditable && this.selection.length); }, /** * Removes the line associated to the given recordID (the index of the row * is found thanks to the old state), then updates the state. * * @param {Object} state * @param {string} recordID */ removeLine: function (state, recordID) { this._setState(state); var $row = this._getRow(recordID); if ($row.length === 0) { return; } if ($row.prop('rowIndex') - 1 === this.currentRow) { this.currentRow = null; this._enableRecordSelectors(); } // destroy widgets first this._destroyFieldWidgets(recordID); // remove the row if (this.state.count >= 4) { $row.remove(); } else { // we want to always keep at least 4 (possibly empty) rows var $emptyRow = this._renderEmptyRow(); $row.replaceWith($emptyRow); // move the empty row we just inserted after last data row const $lastDataRow = this.$('.o_data_row:last'); if ($lastDataRow.length) { $emptyRow.insertAfter($lastDataRow); } } }, /** * Updates the already rendered row associated to the given recordID so that * it fits the given mode. * * @param {string} recordID * @param {string} mode * @returns {Promise} */ setRowMode: function (recordID, mode) { var self = this; var record = self._getRecord(recordID); if (!record) { return Promise.resolve(); } var editMode = (mode === 'edit'); var $row = this._getRow(recordID); this.currentRow = editMode ? $row.prop('rowIndex') - 1 : null; var $tds = $row.children('.o_data_cell'); var oldWidgets = _.clone(this.allFieldWidgets[record.id]); // Prepare options for cell rendering (this depends on the mode) var options = { renderInvisible: editMode, renderWidgets: editMode, }; options.mode = editMode ? 'edit' : 'readonly'; // Switch each cell to the new mode; note: the '_renderBodyCell' // function might fill the 'this.defs' variables with multiple promise // so we create the array and delete it after the rendering. var defs = []; this.defs = defs; _.each(this.columns, function (node, colIndex) { var $td = $tds.eq(colIndex); var $newTd = self._renderBodyCell(record, node, colIndex, options); // Widgets are unregistered of modifiers data when they are // destroyed. This is not the case for simple buttons so we have to // do it here. if ($td.hasClass('o_list_button')) { self._unregisterModifiersElement(node, recordID, $td.children()); } // For edit mode we only replace the content of the cell with its // new content (invisible fields, editable fields, ...). // For readonly mode, we replace the whole cell so that the // dimensions of the cell are not forced anymore. if (editMode) { $td.empty().append($newTd.contents()); } else { self._unregisterModifiersElement(node, recordID, $td); $td.replaceWith($newTd); } }); delete this.defs; // Destroy old field widgets _.each(oldWidgets, this._destroyFieldWidget.bind(this, recordID)); // Toggle selected class here so that style is applied at the end $row.toggleClass('o_selected_row', editMode); if (editMode) { this._disableRecordSelectors(); } else { this._enableRecordSelectors(); } return Promise.all(defs).then(function () { // mark Owl sub components as mounted WidgetAdapterMixin.on_attach_callback.call(self); // necessary to trigger resize on fieldtexts core.bus.trigger('DOM_updated'); }); }, /** * This method is called whenever we click/move outside of a row that was * in edit mode. This is the moment we save all accumulated changes on that * row, if needed (@see BasicController.saveRecord). * * Note that we have to disable the focusable elements (inputs, ...) to * prevent subsequent editions. These edits would be lost, because the list * view only saves records when unselecting a row. * * @returns {Promise} The promise resolves if the row was unselected (and * possibly removed). If may be rejected, when the row is dirty and the * user refuses to discard its changes. */ unselectRow: function () { // Protect against calling this method when no row is selected if (this.currentRow === null) { return Promise.resolve(); } var recordID = this._getRecordID(this.currentRow); var recordWidgets = this.allFieldWidgets[recordID]; function toggleWidgets(disabled) { _.each(recordWidgets, function (widget) { var $el = widget.getFocusableElement(); $el.prop('disabled', disabled); }); } toggleWidgets(true); return new Promise((resolve, reject) => { this.trigger_up('save_line', { recordID: recordID, onSuccess: resolve, onFailure: reject, }); }).then(selectNextRow => { this._enableRecordSelectors(); // If any field has changed and if the list is in multiple edition, // we send a truthy boolean to _selectRow to tell it not to select // the following record. return selectNextRow; }).guardedCatch(() => { toggleWidgets(false); }); }, /** * @override */ updateState: function (state, params) { // There are some cases where a record is added to an invisible list // e.g. set a quotation template with optionnal products if (params.keepWidths && this.$el.is(':visible')) { this._storeColumnWidths(); } if (params.noRender) { // the state changed, but we won't do a re-rendering right now, so // remove computed modifiers data (as they are obsolete) to force // them to be recomputed at next (sub-)rendering this.allModifiersData = []; } if ('addTrashIcon' in params) { if (this.addTrashIcon !== params.addTrashIcon) { this.columnWidths = false; // columns changed, so forget stored widths } this.addTrashIcon = params.addTrashIcon; } if ('addCreateLine' in params) { this.addCreateLine = params.addCreateLine; } return this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Used to bind event listeners so that they can be unbound when the list * is destroyed. * There is no reverse method (list._removeEventListener) because there is * no issue with removing an non-existing listener. * * @private * @param {string} type event name * @param {EventTarget} el event target * @param {Function} callback callback function to attach * @param {Object} options event listener options */ _addEventListener: function (type, el, callback, options) { el.addEventListener(type, callback, options); this.eventListeners.push({ type, el, callback, options }); }, /** * Handles the assignation of default widths for each column header. * If the list is empty, an arbitrary absolute or relative width will be * given to the header * * @see _getColumnWidth for detailed information about which width is * given to a certain field type. * * @private */ _computeDefaultWidths: function () { const isListEmpty = !this._hasVisibleRecords(this.state); const relativeWidths = []; this.columns.forEach(column => { const th = this._getColumnHeader(column); if (th.offsetParent === null) { relativeWidths.push(false); } else { const width = this._getColumnWidth(column); if (width.match(/[a-zA-Z]/)) { // absolute width with measure unit (e.g. 100px) if (isListEmpty) { th.style.width = width; } else { // If there are records, we force a min-width for fields with an absolute // width to ensure a correct rendering in edition th.style.minWidth = width; } relativeWidths.push(false); } else { // relative width expressed as a weight (e.g. 1.5) relativeWidths.push(parseFloat(width, 10)); } } }); // Assignation of relative widths if (isListEmpty) { const totalWidth = this._getColumnsTotalWidth(relativeWidths); for (let i in this.columns) { if (relativeWidths[i]) { const th = this._getColumnHeader(this.columns[i]); th.style.width = (relativeWidths[i] / totalWidth * 100) + '%'; } } // Manualy assigns trash icon header width since it's not in the columns const trashHeader = this.el.getElementsByClassName('o_list_record_remove_header')[0]; if (trashHeader) { trashHeader.style.width = '32px'; } } }, /** * Destroy all field widgets corresponding to a record. Useful when we are * removing a useless row. * * @param {string} recordID */ _destroyFieldWidgets: function (recordID) { if (recordID in this.allFieldWidgets) { var widgetsToDestroy = this.allFieldWidgets[recordID].slice(); _.each(widgetsToDestroy, this._destroyFieldWidget.bind(this, recordID)); delete this.allFieldWidgets[recordID]; } }, /** * When editing a row, we want to disable all record selectors. * * @private */ _disableRecordSelectors: function () { this.$('.o_list_record_selector input').attr('disabled', 'disabled'); }, /** * @private */ _enableRecordSelectors: function () { this.$('.o_list_record_selector input').attr('disabled', false); }, /** * This function freezes the column widths and forces a fixed table-layout, * once the browser has computed the optimal width of each column according * to the displayed records. We want to freeze widths s.t. it doesn't * flicker when we switch a row in edition. * * We skip this when there is no record as we don't want to fix widths * according to column's labels. In this case, we fallback on the 'weight' * heuristic, which assigns to each column a fixed or relative width * depending on the widget or field type. * * Note that the list must be in the DOM when this function is called. * * @private */ _freezeColumnWidths: function () { if (!this.columnWidths && this.el.offsetParent === null) { // there is no record nor widths to restore or the list is not visible // -> don't force column's widths w.r.t. their label return; } const thElements = [...this.el.querySelectorAll('table thead th')]; if (!thElements.length) { return; } const table = this.el.getElementsByClassName('o_list_table')[0]; let columnWidths = this.columnWidths; if (!columnWidths || !columnWidths.length) { // no column widths to restore // Set table layout auto and remove inline style to make sure that css // rules apply (e.g. fixed width of record selector) table.style.tableLayout = 'auto'; thElements.forEach(th => { th.style.width = null; th.style.maxWidth = null; }); // Resets the default widths computation now that the table is visible. this._computeDefaultWidths(); // Squeeze the table by applying a max-width on largest columns to // ensure that it doesn't overflow columnWidths = this._squeezeTable(); } thElements.forEach((th, index) => { // Width already set by default relative width computation if (!th.style.width) { th.style.width = `${columnWidths[index]}px`; } }); // Set the table layout to fixed table.style.tableLayout = 'fixed'; }, /** * Returns the first or last editable row of the list * * @private * @returns {integer} */ _getBorderRow: function (side) { let $borderDataRow = this.$(`.o_data_row:${side}`); if (!this._isRecordEditable($borderDataRow.data('id'))) { $borderDataRow = this._getNearestEditableRow($borderDataRow, side === 'first'); } return $borderDataRow; }, /** * Compute the sum of the weights for each column, given an array containing * all relative widths. param `$thead` is useful for studio, in order to * show column hooks. * * @private * @param {jQuery} $thead * @param {number[]} relativeWidths * @return {integer} */ _getColumnsTotalWidth(relativeWidths) { return relativeWidths.reduce((acc, width) => acc + width, 0); }, /** * Returns the width of a column according the 'width' attribute set in the * arch, the widget or the field type. A fixed width is harcoded for some * field types (e.g. date and numeric fields). By default, the remaining * space is evenly distributed between the other fields (with a factor '1'). * * This is only used when there is no record in the list (i.e. when we can't * let the browser compute the optimal width of each column). * * @see _renderHeader * @private * @param {Object} column an arch node * @returns {string} either a weight factor (e.g. '1.5') or a css width * description (e.g. '120px') */ _getColumnWidth: function (column) { if (column.attrs.width) { return column.attrs.width; } const fieldsInfo = this.state.fieldsInfo.list; const name = column.attrs.name; if (!fieldsInfo[name]) { // Unnamed columns get default value return '1'; } const widget = fieldsInfo[name].Widget.prototype; if ('widthInList' in widget) { return widget.widthInList; } const field = this.state.fields[name]; if (!field) { // this is not a field. Probably a button or something of unknown // width. return '1'; } const fixedWidths = { boolean: '70px', date: '92px', datetime: '146px', float: '92px', integer: '74px', monetary: '104px', }; let type = field.type; if (fieldsInfo[name].widget in fixedWidths) { type = fieldsInfo[name].widget; } return fixedWidths[type] || '1'; }, /** * Gets the th element corresponding to a given column. * * @private * @param {Object} column * @returns {HTMLElement} */ _getColumnHeader: function (column) { const { icon, name, string } = column.attrs; if (name) { return this.el.querySelector(`thead th[data-name="${name}"]`); } else if (string) { return this.el.querySelector(`thead th[data-string="${string}"]`); } else if (icon) { return this.el.querySelector(`thead th[data-icon="${icon}"]`); } }, /** * Returns the nearest editable row starting from a given table row. * If the list is grouped, jumps to the next unfolded group * * @private * @param {jQuery} $row starting point * @param {boolean} next whether the requested row should be the next or the previous one * @return {jQuery|null} */ _getNearestEditableRow: function ($row, next) { const direction = next ? 'next' : 'prev'; let $nearestRow; if (this.editable) { $nearestRow = $row[direction](); if (!$nearestRow.hasClass('o_data_row')) { var $nextBody = $row.closest('tbody')[direction](); while ($nextBody.length && !$nextBody.find('.o_data_row').length) { $nextBody = $nextBody[direction](); } $nearestRow = $nextBody.find(`.o_data_row:${next ? 'first' : 'last'}`); } } else { // In readonly lists, look directly into selected records const recordId = $row.data('id'); const rowSelectionIndex = this.selection.indexOf(recordId); let nextRowIndex; if (rowSelectionIndex < 0) { nextRowIndex = next ? 0 : this.selection.length - 1; } else { nextRowIndex = rowSelectionIndex + (next ? 1 : -1); } // Index might be out of range, will then return an empty jQuery object $nearestRow = this._getRow(this.selection[nextRowIndex]); } return $nearestRow; }, /** * Returns the current number of columns. The editable renderer may add a * trash icon on the right of a record, so we need to take this into account * * @override * @returns {number} */ _getNumberOfCols: function () { var n = this._super(); if (this.addTrashIcon) { n++; } return n; }, /** * Traverse this.state to find and return the record with given dataPoint id * (for grouped list views, the record could be deep down in state tree). * * @override * @private */ _getRecord: function (recordId) { var record; utils.traverse_records(this.state, function (r) { if (r.id === recordId) { record = r; } }); return record; }, /** * Retrieve the record dataPoint id from a rowIndex as the row DOM element * stores the record id in data. * * @private * @param {integer} rowIndex * @returns {string} record dataPoint id */ _getRecordID: function (rowIndex) { var $tr = this.$('table.o_list_table > tbody tr').eq(rowIndex); return $tr.data('id'); }, /** * Return the jQuery tr element corresponding to the given record dataPoint * id. * * @private * @param {string} [recordId] * @returns {jQueryElement} */ _getRow: function (recordId) { return this.$('.o_data_row[data-id="' + recordId + '"]'); }, /** * This function returns true iff records are visible in the list, i.e. * if the list is ungrouped: true iff the list isn't empty; * if the list is grouped: true iff there is at least one unfolded group * containing records. * * @param {Object} list a datapoint * @returns {boolean} */ _hasVisibleRecords: function (list) { if (!list.groupedBy.length) { return !!list.data.length; } else { var hasVisibleRecords = false; for (var i = 0; i < list.data.length; i++) { hasVisibleRecords = hasVisibleRecords || this._hasVisibleRecords(list.data[i]); } return hasVisibleRecords; } }, /** * Returns whether a recordID is currently editable. * * @param {string} recordID * @returns {boolean} */ _isRecordEditable: function (recordID) { return this.editable || (this.isMultiEditable && this.selection.includes(recordID)); }, /** * Moves to the next row in the list * * @private * @params {Object} [options] see @_moveToSideLine */ _moveToNextLine: function (options) { this._moveToSideLine(true, options); }, /** * Moves to the previous row in the list * * @private * @params {Object} [options] see @_moveToSideLine */ _moveToPreviousLine: function (options) { this._moveToSideLine(false, options); }, /** * Moves the focus to the nearest editable row before or after the current one. * If we arrive at the end of the list (or of a group in the grouped case) and the list * is editable="bottom", we create a new record, otherwise, we move the * cursor to the first row (of the next group in the grouped case). * * @private * @param {number} next whether to move to the next or previous row * @param {Object} [options] * @param {boolean} [options.forceCreate=false] typically set to true when * navigating with ENTER ; in this case, if the next row is the 'Add a * row' one, always create a new record (never skip it, like TAB does * under some conditions) */ _moveToSideLine: function (next, options) { options = options || {}; const recordID = this._getRecordID(this.currentRow); this.commitChanges(recordID).then(() => { const record = this._getRecord(recordID); const multiEdit = this.isInMultipleRecordEdition(recordID); if (!multiEdit) { const fieldNames = this.canBeSaved(recordID); if (fieldNames.length && (record.isDirty() || options.forceCreate)) { // the current row is invalid, we only leave it if it is not dirty // (we didn't make any change on this row, which is a new one) and // we are navigating with TAB (forceCreate=false) return; } } // compute the index of the next (record) row to select, if any const side = next ? 'first' : 'last'; const borderRowIndex = this._getBorderRow(side).prop('rowIndex') - 1; const cellIndex = next ? 0 : this.allFieldWidgets[recordID].length - 1; const cellOptions = { inc: next ? 1 : -1, force: true }; const $currentRow = this._getRow(recordID); const $nextRow = this._getNearestEditableRow($currentRow, next); let nextRowIndex = null; let groupId; if (!this.isGrouped) { // ungrouped case if ($nextRow.length) { nextRowIndex = $nextRow.prop('rowIndex') - 1; } else if (!this.editable) { nextRowIndex = borderRowIndex; } else if (!options.forceCreate && !record.isDirty()) { this.trigger_up('discard_changes', { recordID: recordID, onSuccess: this.trigger_up.bind(this, 'activate_next_widget', { side: side }), }); return; } } else { // grouped case var $directNextRow = $currentRow.next(); if (next && this.editable === "bottom" && $directNextRow.hasClass('o_add_record_row')) { // the next row is the 'Add a line' row (i.e. the current one is the last record // row of the group) if (options.forceCreate || record.isDirty()) { // if we modified the current record, add a row to create a new record groupId = $directNextRow.data('group-id'); } else { // if we didn't change anything to the current line (e.g. we pressed TAB on // each cell without modifying/entering any data), we discard that line (if // it was a new one) and move to the first record of the next group nextRowIndex = ($nextRow.prop('rowIndex') - 1) || null; this.trigger_up('discard_changes', { recordID: recordID, onSuccess: () => { if (nextRowIndex !== null) { if (!record.res_id) { // the current record was a new one, so we decrement // nextRowIndex as that row has been removed meanwhile nextRowIndex--; } this._selectCell(nextRowIndex, cellIndex, cellOptions); } else { // we were in the last group, so go back to the top this._selectCell(borderRowIndex, cellIndex, cellOptions); } }, }); return; } } else { // there is no 'Add a line' row (i.e. the create feature is disabled), or the // list is editable="top", we focus the first record of the next group if any, // or we go back to the top of the list nextRowIndex = $nextRow.length ? ($nextRow.prop('rowIndex') - 1) : borderRowIndex; } } // if there is a (record) row to select, select it, otherwise, add a new record (in the // correct group, if the view is grouped) if (nextRowIndex !== null) { // cellOptions.force = true; this._selectCell(nextRowIndex, cellIndex, cellOptions); } else if (this.editable) { // if for some reason (e.g. create feature is disabled) we can't add a new // record, select the first record row this.unselectRow().then(this.trigger_up.bind(this, 'add_record', { groupId: groupId, onFail: this._selectCell.bind(this, borderRowIndex, cellIndex, cellOptions), })); } }); }, /** * Override to compute the (relative or absolute) width of each column. * * @override * @private */ _processColumns: function () { const oldColumns = this.columns; this._super.apply(this, arguments); // check if stored widths still apply if (this.columnWidths && oldColumns && oldColumns.length === this.columns.length) { for (let i = 0; i < oldColumns.length; i++) { if (oldColumns[i] !== this.columns[i]) { this.columnWidths = false; // columns changed, so forget stored widths break; } } } else { this.columnWidths = false; // columns changed, so forget stored widths } }, /** * @override * @returns {Promise} */ _render: function () { this.currentRow = null; this.currentFieldIndex = null; return this._super.apply(this, arguments); }, /** * Override to add the 'Add an item' link to the end of last-level opened * groups. * * @override * @private */ _renderGroup: function (group) { var result = this._super.apply(this, arguments); if (!group.groupedBy.length && this.addCreateLineInGroups) { var $groupBody = result[0]; var $a = $('') .text(_t("Add a line")) .attr('data-group-id', group.id); var $td = $('') .attr('colspan', this._getNumberOfCols()) .addClass('o_group_field_row_add') .attr('tabindex', -1) .append($a); var $tr = $('', {class: 'o_add_record_row'}) .attr('data-group-id', group.id) .append($td); $groupBody.append($tr.prepend($('').html(' '))); } return result; }, /** * The renderer needs to support reordering lines. This is only active in * edit mode. The handleField attribute is set when there is a sequence * widget. * * @override */ _renderBody: function () { var self = this; var $body = this._super.apply(this, arguments); if (this.hasHandle) { $body.sortable({ axis: 'y', items: '> tr.o_data_row', helper: 'clone', handle: '.o_row_handle', stop: function (event, ui) { // update currentID taking moved line into account if (self.currentRow !== null) { var currentID = self.state.data[self.currentRow].id; self.currentRow = self._getRow(currentID).index(); } self.unselectRow().then(function () { self._moveRecord(ui.item.data('id'), ui.item.index()); }); }, }); } return $body; }, /** * @override * @private */ _renderFooter: function () { const $footer = this._super.apply(this, arguments); if (this.addTrashIcon) { $footer.find('tr').append($('')); } return $footer; }, /** * Override to optionally add a th in the header for the remove icon column. * * @override * @private */ _renderHeader: function () { var $thead = this._super.apply(this, arguments); if (this.addTrashIcon) { $thead.find('tr').append($('', {class: 'o_list_record_remove_header'})); } return $thead; }, /** * Overriden to add a resize handle in editable list column headers. * Only applies to headers containing text. * * @override * @private */ _renderHeaderCell: function () { const $th = this._super.apply(this, arguments); if ($th[0].innerHTML.length && this._hasVisibleRecords(this.state)) { const resizeHandle = document.createElement('span'); resizeHandle.classList = 'o_resize'; resizeHandle.onclick = this._onClickResize.bind(this); resizeHandle.onmousedown = this._onStartResize.bind(this); $th.append(resizeHandle); } return $th; }, /** * Editable rows are possibly extended with a trash icon on their right, to * allow deleting the corresponding record. * For many2many editable lists, the trash bin is replaced by X. * * @override * @param {any} record * @param {any} index * @returns {jQueryElement} */ _renderRow: function (record, index) { var $row = this._super.apply(this, arguments); if (this.addTrashIcon) { var $icon = this.isMany2Many ? $('