diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js index dd72f8b403f..41cc5928c6a 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -658,6 +658,7 @@ QUnit.test('dashboard intercepts custom events triggered by sub controllers', as testUtils.mock.patch(ListController, { start: function () { this.trigger_up('update_filters'); + return this._super.apply(this, arguments); }, }); diff --git a/addons/hr_skills/static/src/js/resume_widget.js b/addons/hr_skills/static/src/js/resume_widget.js index 906d26b0435..413942badf6 100644 --- a/addons/hr_skills/static/src/js/resume_widget.js +++ b/addons/hr_skills/static/src/js/resume_widget.js @@ -28,22 +28,33 @@ var AbstractGroupedOne2ManyRenderer = ListRenderer.extend({ dataRowTemplate: '', // Template used to render a record addLineButtonTemplate: 'group_add_item', - /** + /** + * Don't freeze the columns because as the header is empty, the algorithm + * won't work. + * + * @override + * @private + */ + _freezeColumnWidths: function () {}, + + /** + * Renders a empty header + * * @override * @private - * Don't render any header */ _renderHeader: function () { - return $('
'); + return $(''); }, /** + * Renders a empty footer + * * @override * @private - * Don't render any footer */ _renderFooter: function () { - return $('
'); + return $(''); }, /** diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 54e4d89478a..3687c622039 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -1604,7 +1604,9 @@ var FieldX2Many = AbstractField.extend({ _onActiveNextWidget: function (e) { e.stopPropagation(); this.renderer.unselectRow(); - this.trigger_up('navigation_move',{direction:'next'}); + this.trigger_up('navigation_move', { + direction: e.data.direction || 'next', + }); }, }); diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index 309550e86bb..611079ca3d5 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -55,19 +55,10 @@ var ListController = BasicController.extend({ this.selectedRecords = params.selectedRecords || []; this.multipleRecordsSavingPromise = null; this.fieldChangedPrevented = false; - this._onMouseupWindowDiscard = null; - }, - - /** - * Overriden to properly unbind any mouseup listeners still bound to the window - * - * @override - */ - destroy: function () { - if (this._onMouseupWindowDiscard) { - window.removeEventListener('mouseup', this._onMouseupWindowDiscard, true); - } - return this._super.apply(this, arguments); + Object.defineProperty(this, 'mode', { + get: () => this.renderer.isEditable() ? 'edit' : 'readonly', + set: () => {}, + }); }, //-------------------------------------------------------------------------- @@ -455,7 +446,7 @@ var ListController = BasicController.extend({ */ _saveRecord: function (recordId) { var record = this.model.get(recordId, { raw: true }); - if (record.isDirty() && this.renderer.inMultipleRecordEdition(recordId)) { + if (record.isDirty() && this.renderer.isInMultipleRecordEdition(recordId)) { // do not save the record (see _saveMultipleRecords) const prom = this.multipleRecordsSavingPromise || Promise.reject(); this.multipleRecordsSavingPromise = null; @@ -498,7 +489,7 @@ var ListController = BasicController.extend({ _toggleCreateButton: function () { if (this.$buttons) { var state = this.model.get(this.handle); - var createHidden = this.editable && state.groupedBy.length && state.data.length; + var createHidden = this.renderer.isEditable() && state.groupedBy.length && state.data.length; this.$buttons.find('.o_list_button_add').toggleClass('o_hidden', !!createHidden); } }, @@ -516,8 +507,6 @@ var ListController = BasicController.extend({ * @returns {Promise} */ _update: function () { - let visilibityFunction = this.renderer.state.count ? 'show' : 'hide' - this.$('.o_list_export_xlsx')[visilibityFunction]() return this._super.apply(this, arguments) .then(this._toggleSidebar.bind(this)) .then(this._toggleCreateButton.bind(this)) @@ -532,6 +521,12 @@ var ListController = BasicController.extend({ _updateButtons: function (mode) { if (this.$buttons) { this.$buttons.toggleClass('o-editing', mode === 'edit'); + const state = this.model.get(this.handle, {raw: true}); + if (state.count) { + this.$('.o_list_export_xlsx').show(); + } else { + this.$('.o_list_export_xlsx').hide(); + } } }, @@ -548,7 +543,7 @@ var ListController = BasicController.extend({ */ _onActivateNextWidget: function (ev) { ev.stopPropagation(); - this.renderer.editFirstRecord(); + this.renderer.editFirstRecord(ev); }, /** * Add a record to the list @@ -701,12 +696,14 @@ var ListController = BasicController.extend({ if (this.fieldChangedPrevented) { this.fieldChangedPrevented = ev; - } else if (this.renderer.inMultipleRecordEdition(recordId)) { - // deal with edition of multiple lines - ev.data.onSuccess = () => { + } else if (this.renderer.isInMultipleRecordEdition(recordId)) { + const saveMulti = () => { this.multipleRecordsSavingPromise = this._saveMultipleRecords(ev.data.dataPointID, ev.target.__node, ev.data.changes); }; + // deal with edition of multiple lines + ev.data.onSuccess = saveMulti; // will ask confirmation, and save + ev.data.onFailure = saveMulti; // will show the appropriate dialog // disable onchanges as we'll save directly ev.data.notifyChange = false; } @@ -744,7 +741,7 @@ var ListController = BasicController.extend({ */ _onSetDirty: function (ev) { var recordId = ev.data.dataPointID; - if (this.renderer.inMultipleRecordEdition(recordId)) { + if (this.renderer.isInMultipleRecordEdition(recordId)) { ev.stopPropagation(); Dialog.alert(this, _t("No valid record to save"), { confirm_callback: async () => { diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index c9fd1cea92b..70ea31aab01 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -99,7 +99,6 @@ ListRenderer.include({ this.currentRow = null; this.currentFieldIndex = null; - this.allRecordsIds = null; // flat array of records ids used by navigation this.isResizing = false; this.eventListeners = []; }, @@ -108,10 +107,8 @@ ListRenderer.include({ * @returns {Promise} */ start: function () { - if (this.editable) { - 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('click', this, this._onWindowClicked.bind(this)); + core.bus.on('resize', this, _.debounce(this._onResize.bind(this), this.RESIZE_DELAY)); return this._super(); }, /** @@ -134,9 +131,7 @@ ListRenderer.include({ */ on_attach_callback: function () { this.isInDOM = true; - if (this.editable) { - this._freezeColumnWidths(); - } + this._freezeColumnWidths(); this._super(); }, /** @@ -308,8 +303,9 @@ ListRenderer.include({ /** * Edit the first record in the list */ - editFirstRecord: function () { - this._selectCell(this._getFirstDataRowIndex(), 0); + 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 @@ -351,8 +347,19 @@ ListRenderer.include({ * @param {string} recordId * @returns {boolean} */ - inMultipleRecordEdition: function (recordId) { - return this.editable && this.selection.length > 1 && this.selection.includes(recordId); + isInMultipleRecordEdition: function (recordId) { + return this.isEditable() && this.selection.length > 1 && 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.selection.length; }, /** * Removes the line associated to the given recordID (the index of the row @@ -502,7 +509,7 @@ ListRenderer.include({ // 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 changedFields && changedFields.length && this.inMultipleRecordEdition(recordID); + return changedFields && changedFields.length && this.isInMultipleRecordEdition(recordID); }).guardedCatch(() => { toggleWidgets(false); }); @@ -546,14 +553,6 @@ ListRenderer.include({ el.addEventListener(type, callback, options); this.eventListeners.push({ type, el, callback, options }); }, - /** - * When editing a row, we want to disable all record selectors. - * - * @private - */ - _disableRecordSelectors: function () { - this.$('.o_list_record_selector input').attr('disabled', 'disabled'); - }, /** * Destroy all field widgets corresponding to a record. Useful when we are * removing a useless row. @@ -567,6 +566,14 @@ ListRenderer.include({ 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 */ @@ -620,6 +627,19 @@ ListRenderer.include({ // 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; + }, /** * 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 @@ -670,29 +690,40 @@ ListRenderer.include({ return fixedWidths[type] || '1'; }, /** + * Returns the nearest editable row starting from a given table row. + * If the list is grouped, jumps to the next unfolded group + * * @private - * - * @returns {integer} + * @param {jQuery} $row starting point + * @param {boolean} next whether the requested row should be the next or the previous one + * @return {jQuery|null} */ - _getFirstDataRowIndex: function () { - return this.$('.o_data_row:first').prop('rowIndex') - 1; - }, - /** - * Given a table row inside a group, returns the index of the first data - * row of the next group (if any). - * - * @param {jQuery} $row this row must be inside a group - * @returns {integer|null} - */ - _getNextGroupFirstRowIndex: function ($row) { - var $nextBody = $row.closest('tbody').next(); - while ($nextBody.length && !$nextBody.find('.o_data_row').length) { - $nextBody = $nextBody.next(); + _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]); } - if ($nextBody.find('.o_data_row').length) { - return $nextBody.find('.o_data_row:first').prop('rowIndex') - 1; - } - return null; + return $nearestRow; }, /** * Returns the current number of columns. The editable renderer may add a @@ -768,104 +799,111 @@ ListRenderer.include({ } }, /** - * Move the cursor on the end of the previous line (or of the last line if - * we are on the first one). + * Returns whether a recordID is currently editable. * - * @private + * @param {string} recordID + * @returns {boolean} */ - _moveToPreviousLine: function () { - var self = this; - if (!this.allRecordsIds) { - // compute the flat array of all records ids only once - this.allRecordsIds = []; - utils.traverse_records(this.state, function (data) { - self.allRecordsIds.push(data.id); - }); - } - var curRecordId = this._getRecordID(this.currentRow); - var curRecordIndex = this.allRecordsIds.indexOf(curRecordId); - var prevRecordIndex = curRecordIndex === 0 ? this.allRecordsIds.length - 1 : curRecordIndex - 1; - this.commitChanges(curRecordId).then(function () { - var $prevRow = self._getRow(self.allRecordsIds[prevRecordIndex]); - var prevRowIndex = $prevRow.prop('rowIndex') - 1; - self._selectCell(prevRowIndex, self.columns.length - 1, {inc: -1}); - }); + _isRecordEditable: function (recordID) { + return this.editable || this.selection.includes(recordID); }, /** - * Move the cursor on the beginning of the next line, if possible. - * If we are on the last line (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 line (of the next group in the grouped case). + * 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 - * line' row, always create a new record (never skip it, like TAB does + * row' one, always create a new record (never skip it, like TAB does * under some conditions) */ - _moveToNextLine: function (options) { - var self = this; + _moveToSideLine: function (next, options) { options = options || {}; - var recordID = this._getRecordID(this.currentRow); - var record = this._getRecord(recordID); - - this.commitChanges(recordID).then(function () { - var fieldNames = self.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; + 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 - var nextRowIndex = null; - var groupId; - if (!self.isGrouped) { + 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 (self.currentRow < self.state.data.length - 1) { - nextRowIndex = self.currentRow + 1; + if ($nextRow.length) { + nextRowIndex = $nextRow.prop('rowIndex') - 1; + } else if (!this.editable) { + nextRowIndex = borderRowIndex; } else if (!options.forceCreate && !record.isDirty()) { - self.trigger_up('discard_changes', { + this.trigger_up('discard_changes', { recordID: recordID, - onSuccess: function () { - self.trigger_up('activate_next_widget'); - }, + onSuccess: this.trigger_up.bind(this, 'activate_next_widget', { side: side }), }); return; } } else { // grouped case - var $currentRow = self._getRow(recordID); - var $nextRow = $currentRow.next(); - if ($nextRow.hasClass('o_data_row')) { - // the next row is a record row (in same group), select it - nextRowIndex = self.currentRow + 1; - } else if ($nextRow.hasClass('o_add_record_row') && self.editable === "bottom") { + 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 = $nextRow.data('group-id'); + 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 = self._getNextGroupFirstRowIndex($currentRow); - self.trigger_up('discard_changes', { + nextRowIndex = ($nextRow.prop('rowIndex') - 1) || null; + this.trigger_up('discard_changes', { recordID: recordID, - onSuccess: function () { + 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--; } - self._selectCell(nextRowIndex, 0); + this._selectCell(nextRowIndex, cellIndex, cellOptions); } else { // we were in the last group, so go back to the top - self._selectCell(self._getFirstDataRowIndex(), 0, {}); + this._selectCell(borderRowIndex, cellIndex, cellOptions); } }, }); @@ -873,28 +911,26 @@ ListRenderer.include({ } } 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 - nextRowIndex = self._getNextGroupFirstRowIndex($currentRow); - if (nextRowIndex === null) { - // we were on the last group, so we go back to the top of the list - nextRowIndex = self._getFirstDataRowIndex(); - } + // 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) { - self._selectCell(nextRowIndex, 0); - } else { - self.unselectRow().then(function () { - // if for some reason (e.g. create feature is disabled) we can't add a new - // record, select the first record row - self.trigger_up('add_record', { - groupId: groupId, - onFail: self._selectCell.bind(self, self._getFirstDataRowIndex(), 0, {}), - }); - }); + // 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), + })); } }); }, @@ -907,29 +943,27 @@ ListRenderer.include({ _processColumns: function () { const oldColumns = this.columns; this._super.apply(this, arguments); - if (this.editable) { - // 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; - } + // 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 - } - // if we don't have widths yet, computed them - if (!this.columnWidths) { - this.columns.forEach((column) => { - const width = this._getColumnWidth(column); - if (width.match(/[a-zA-Z]/)) { // absolute width with measure unit (e.g. 100px) - column.attrs.absoluteWidth = width; - } else { // relative width expressed as a weight (e.g. 1.5) - column.attrs.relativeWidth = parseFloat(width, 10); - } - }); } + } else { + this.columnWidths = false; // columns changed, so forget stored widths + } + // if we don't have widths yet, computed them + if (!this.columnWidths) { + this.columns.forEach((column) => { + const width = this._getColumnWidth(column); + if (width.match(/[a-zA-Z]/)) { // absolute width with measure unit (e.g. 100px) + column.attrs.absoluteWidth = width; + } else { // relative width expressed as a weight (e.g. 1.5) + column.attrs.relativeWidth = parseFloat(width, 10); + } + }); } }, /** @@ -1017,7 +1051,7 @@ ListRenderer.include({ _renderHeader: function () { var $thead = this._super.apply(this, arguments); - if (this.editable && !this.columnWidths) { + if (!this.columnWidths) { if (!this._hasVisibleRecords(this.state)) { // we compute the sum of the weights for each columns, excluding // those with an absolute width. @@ -1061,7 +1095,7 @@ ListRenderer.include({ */ _renderHeaderCell: function () { const $th = this._super.apply(this, arguments); - if (this.editable && $th[0].innerHTML.length && this._hasVisibleRecords(this.state)) { + if ($th[0].innerHTML.length && this._hasVisibleRecords(this.state)) { const resizeHandle = document.createElement('span'); resizeHandle.classList = 'o_resize'; resizeHandle.onclick = this._onClickResize.bind(this); @@ -1136,21 +1170,12 @@ ListRenderer.include({ * @returns {Promise} this promise is resolved immediately */ _renderView: function () { - var self = this; this.currentRow = null; - this.allRecordsIds = null; - return this._super.apply(this, arguments).then(function () { - var table = self.el.getElementsByTagName('table')[0]; - if (table) { // no table if no content helper displayed - if (!self._hasVisibleRecords(self.state)) { - table.classList.add('o_empty_list'); - } - if (self.editable) { - table.classList.add('o_editable_list'); - if (self.isInDOM) { - self._freezeColumnWidths(); - } - } + return this._super.apply(this, arguments).then(() => { + const table = this.el.getElementsByTagName('table')[0]; + if (table) { + table.classList.toggle('o_empty_list', !this._hasVisibleRecords(this.state)); + this._freezeColumnWidths(); } }); }, @@ -1378,12 +1403,12 @@ ListRenderer.include({ _onCellClick: function (event) { // The special_click property explicitely allow events to bubble all // the way up to bootstrap's level rather than being stopped earlier. - if (!this.editable || $(event.target).prop('special_click')) { - return; - } var $td = $(event.currentTarget); var $tr = $td.parent(); var rowIndex = $tr.prop('rowIndex') - 1; + if (!this._isRecordEditable($tr.data('id')) || $(event.target).prop('special_click')) { + return; + } var fieldIndex = Math.max($tr.find('.o_field_cell').index($td), 0); this._selectCell(rowIndex, fieldIndex, {event: event}); }, @@ -1420,15 +1445,17 @@ ListRenderer.include({ * @override */ _onKeyDown: function (ev) { - var $target = $(ev.currentTarget); - var $tr = $target.closest('tr'); + const $target = $(ev.currentTarget); + const $tr = $target.closest('tr'); + const recordEditable = this._isRecordEditable($tr.data('id')); - if (this.editable && ev.keyCode === $.ui.keyCode.ENTER && $tr.hasClass('o_selected_row')) { + if (recordEditable && ev.keyCode === $.ui.keyCode.ENTER && $tr.hasClass('o_selected_row')) { // enter on a textarea for example, let it bubble return; } - if (this.editable && ev.keyCode === $.ui.keyCode.ENTER && !$tr.hasClass('o_selected_row') && !$tr.hasClass('o_group_header')) { + if (recordEditable && ev.keyCode === $.ui.keyCode.ENTER && + !$tr.hasClass('o_selected_row') && !$tr.hasClass('o_group_header')) { ev.stopPropagation(); ev.preventDefault(); if ($target.closest('td').hasClass('o_group_field_row_add')) { @@ -1493,7 +1520,13 @@ ListRenderer.include({ } break; case 'next_line': - this._moveToNextLine({forceCreate: true}); + // If the list is readonly and the current is the only record editable, we unselect the line + if (!this.editable && this.selection.length === 1 && + this._getRecordID(this.currentRow) === ev.target.dataPointID) { + this.unselectRow(); + } else { + this._moveToNextLine({ forceCreate: true }); + } break; case 'cancel': // stop the original event (typically an ESCAPE keydown), to @@ -1555,9 +1588,14 @@ ListRenderer.include({ * @override * @private */ - _onRowClicked: function () { - if (!this.editable) { - this._super.apply(this, arguments); + _onRowClicked: function (ev) { + if (!this._isRecordEditable(ev.currentTarget.dataset.id)) { + // If there is an edited record, tries to save it and do not open the clicked record + if (this.getEditableRecordID()) { + this.unselectRow(); + } else { + this._super.apply(this, arguments); + } } }, /** @@ -1681,6 +1719,11 @@ ListRenderer.include({ * @param {MouseEvent} event */ _onWindowClicked: function (event) { + // ignore clicks on readonly lists with no selected rows + if (!this.isEditable()) { + return; + } + // ignore clicks if this renderer is not in the dom. if (!document.contains(this.el)) { return; diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index 468ac4d15c1..53a60d011e6 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -39,10 +39,10 @@ var ListRenderer = BasicRenderer.extend({ "click .o_optional_columns_dropdown .dropdown-item": "_onToggleOptionalColumn", "click .o_optional_columns_dropdown_toggle": "_onToggleOptionalColumnDropdown", 'click tbody tr': '_onRowClicked', - 'click tbody .o_list_record_selector': '_onSelectRecord', + 'change tbody .o_list_record_selector': '_onSelectRecord', 'click thead th.o_column_sortable': '_onSortColumn', 'click .o_group_header': '_onToggleGroup', - 'click thead .o_list_record_selector input': '_onToggleSelection', + 'change thead .o_list_record_selector input': '_onToggleSelection', 'keypress thead tr td': '_onKeyPress', 'keydown td': '_onKeyDown', 'keydown th': '_onKeyDown', @@ -1196,7 +1196,7 @@ var ListRenderer = BasicRenderer.extend({ _onRowClicked: function (ev) { // The special_click property explicitely allow events to bubble all // the way up to bootstrap's level rather than being stopped earlier. - if (!$(ev.target).prop('special_click')) { + if (!ev.target.closest('.o_list_record_selector') && !$(ev.target).prop('special_click')) { var id = $(ev.currentTarget).data('id'); if (id) { this.trigger_up('open_record', { id: id, target: ev.target }); diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss index ceeefc1e637..c9562df798b 100644 --- a/addons/web/static/src/scss/list_view.scss +++ b/addons/web/static/src/scss/list_view.scss @@ -34,13 +34,9 @@ &.o_list_number { text-align: right; } - &.o_list_char:not(.o_color_cell), - &.o_list_text, - &.o_list_many2one { - white-space: pre-wrap; - } &.o_list_text { word-break: break-word; + white-space: pre-wrap; } &.o_list_char.o_color_cell .o_field_color { width: 17px; @@ -56,6 +52,9 @@ border-top: 2px solid #cacaca; border-bottom: 1px solid #cacaca; + > tr > td { + @include o-text-overflow(table-cell); + } .o_list_number { text-align: right; } @@ -181,107 +180,102 @@ display: table-cell!important; } - //-------------------------------------------------------------------------- - // Edition - //-------------------------------------------------------------------------- + &.o_resizing tr > :not(.o_column_resizing) { + opacity: 0.5; + } - &.o_editable_list { - &.o_resizing tr > :not(.o_column_resizing) { - opacity: 0.5; - } + &.o_empty_list { + table-layout: fixed; + } - &.o_empty_list { - table-layout: fixed; - } + th { + position: relative; + } - th { - position: relative; - } + span.o_resize { + bottom: 0; + cursor: col-resize; + position: absolute; + right: 0; + top: 0; + width: 10px; + z-index: 1; // must be over the sort caret (::after pseudo-element) + } - span.o_resize { - bottom: 0; - cursor: col-resize; - position: absolute; - right: 0; - top: 0; - width: 10px; - z-index: 1; // must be over the sort caret (::after pseudo-element) - } + .o_list_record_remove_header { + width: 32px; + } - .o_list_record_remove_header { - width: 32px; - } - // checkboxes in editable lists should be clickable and activate the row - .o_data_row:not(.o_selected_row) .o_data_cell { + // checkboxes in editable lists should be clickable and activate the row + .o_data_row:not(.o_selected_row) .o_data_cell { &.o_toggle_button_cell:not(.o_readonly_modifier) button, // registry: toggle_button &.o_boolean_favorite_cell:not(.o_readonly_modifier) .o_favorite, // registry: boolean_favorite - .custom-checkbox:not(.o_readonly_modifier) { - pointer-events: none; + .custom-checkbox:not(.o_readonly_modifier) { + pointer-events: none; + } + } + + .o_data_row:not(.o_selected_row) { + .o_list_many2one, + .o_list_char, + .o_list_number { + white-space: nowrap; + .o_field_widget:not(.o_row_handle) { + display: inline; } } + } - .o_data_row:not(.o_selected_row) { - .o_list_many2one, - .o_list_char, - .o_list_number { - white-space: nowrap; - .o_field_widget { - display: inline; - } - } + .o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier):not(.o_invisible_modifier) { + position: relative; // for o_field_translate + background-color: white; + .o_input { + border: none; + padding: 0; } - - .o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier):not(.o_invisible_modifier) { - position: relative; // for o_field_translate - background-color: white; - .o_input { - border: none; + > .o_field_text { + vertical-align: top; + } + > .o_field_widget { + width: 100%; + > .o_external_button { padding: 0; + border: none; + background-color: inherit; + margin-left: 5px; + font-size: 12px; } - > .o_field_text { + } + > .o_field_monetary input { + width: 0; // override width: 100px and let flex makes this input grow + margin: 0 4px; + } + > .o_field_many2manytags > .o_field_many2one { + // reduce basis to prevent many2one input from going to the next line to often + flex-basis: 40px; + } + .o_input_dropdown, .o_datepicker { + > .o_input { + padding-right: 15px; // to prevent caret overlapping + } + > .o_dropdown_button, .o_datepicker_button { + margin-right: 5px; + @include o-position-absolute(0, 0); + } + } + > input.o_field_translate, textarea.o_field_translate { + padding-right: 25px; + + span.o_field_translate { + margin-left: -35px; + padding: 0px 1px; + text-align: right; + width: 35px; vertical-align: top; + font-size: 12px; } - > .o_field_widget { - width: 100%; - > .o_external_button { - padding: 0; - border: none; - background-color: inherit; - margin-left: 5px; - font-size: 12px; - } - } - > .o_field_monetary input { - width: 0; // override width: 100px and let flex makes this input grow - margin: 0 4px; - } - > .o_field_many2manytags > .o_field_many2one { - // reduce basis to prevent many2one input from going to the next line to often - flex-basis: 40px; - } - .o_input_dropdown, .o_datepicker { - > .o_input { - padding-right: 15px; // to prevent caret overlapping - } - > .o_dropdown_button, .o_datepicker_button { - margin-right: 5px; - @include o-position-absolute(0, 0); - } - } - > input.o_field_translate, textarea.o_field_translate { - padding-right: 25px; - + span.o_field_translate { - margin-left: -35px; - padding: 0px 1px; - text-align: right; - width: 35px; - vertical-align: top; - font-size: 12px; - } - } - > .o_row_handle { - visibility: hidden; // hide sequence when editing - } + } + > .o_row_handle { + visibility: hidden; // hide sequence when editing } } diff --git a/addons/web/static/src/scss/list_view_extra.scss b/addons/web/static/src/scss/list_view_extra.scss index 2cef58a57a7..ef51cfd65e0 100644 --- a/addons/web/static/src/scss/list_view_extra.scss +++ b/addons/web/static/src/scss/list_view_extra.scss @@ -12,7 +12,7 @@ } } - .o_list_table.o_editable_list .o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier):not(.o_invisible_modifier) { + .o_list_table .o_data_row.o_selected_row > .o_data_cell:not(.o_readonly_modifier):not(.o_invisible_modifier) { padding: .15rem; .o_input { border: none; diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 1f83783dc33..a889d8be389 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -5726,6 +5726,100 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('editable list view: multi edition server error handling', async function (assert) { + assert.expect(3); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '' + + '' + + '', + mockRPC: function (route, args) { + if (args.method === 'write') { + return Promise.reject(); + } + return this._super.apply(this, arguments); + }, + }); + + // select two records + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input')); + + // edit a line and confirm + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)')); + await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=foo]'), "abc"); + await testUtils.dom.click('body'); + await testUtils.dom.click($('.modal .btn-primary')); + // Server error: if there was a crash manager, there would be an open error at this point... + assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop', + "first cell should have discarded any change"); + assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), 'blip', + "second selected record should not have changed"); + assert.containsNone(list, '.o_data_cell input.o_field_widget', + "no field should be editable anymore"); + + list.destroy(); + }); + + QUnit.test('editable readonly list view: navigation', async function (assert) { + assert.expect(6); + + const list = await createView({ + arch: ` + + + + `, + data: this.data, + intercepts: { + switch_view: function (event) { + assert.strictEqual(event.data.res_id, 3, + "'switch_view' event has been triggered"); + }, + }, + model: 'foo', + View: ListView, + }); + + // select 2 records + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_data_row:eq(3) .o_list_record_selector input')); + + // toggle a row mode + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:eq(1)')); + assert.hasClass(list.$('.o_data_row:eq(1)'), 'o_selected_row', + "the second row should be selected"); + + // Keyboard navigation only interracts with selected elements + await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input.o_field_widget'), 'enter'); + assert.hasClass(list.$('.o_data_row:eq(3)'), 'o_selected_row', + "the fourth row should be selected"); + + await testUtils.fields.triggerKeydown($(document.activeElement), 'tab'); + await testUtils.fields.triggerKeydown($(document.activeElement), 'tab'); + assert.hasClass(list.$('.o_data_row:eq(1)'), 'o_selected_row', + "the second row should be selected again"); + + await testUtils.fields.triggerKeydown($(document.activeElement), 'tab'); + await testUtils.fields.triggerKeydown($(document.activeElement), 'tab'); + assert.hasClass(list.$('.o_data_row:eq(3)'), 'o_selected_row', + "the fourth row should be selected again"); + + await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_data_cell:eq(0)')); + assert.containsNone(list, '.o_data_cell input.o_field_widget', + "no row should be editable anymore"); + // Clicking on an unselected record while no row is being edited will open the record (switch_view) + await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_data_cell:eq(0)')); + + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_data_row:eq(3) .o_list_record_selector input')); + + list.destroy(); + }); + QUnit.test('editable list view: multi edition: edit and validate last row', async function (assert) { assert.expect(3); @@ -5761,6 +5855,160 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('editable readonly list view: navigation in grouped list', async function (assert) { + assert.expect(6); + + const list = await createView({ + arch: ` + + + `, + data: this.data, + groupBy: ['bar'], + intercepts: { + switch_view: function (event) { + assert.strictEqual(event.data.res_id, 3, + "'switch_view' event has been triggered"); + }, + }, + model: 'foo', + View: ListView, + }); + + // Open both groups + await testUtils.dom.click(list.$('.o_group_header:first')); + await testUtils.dom.click(list.$('.o_group_header:last')); + + // select 2 records + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_data_row:eq(3) .o_list_record_selector input')); + + // toggle a row mode + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:eq(0)')); + assert.hasClass(list.$('.o_data_row:eq(1)'), 'o_selected_row', + "the second row should be selected"); + + // Keyboard navigation only interracts with selected elements + await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input.o_field_widget'), 'enter'); + assert.hasClass(list.$('.o_data_row:eq(3)'), 'o_selected_row', + "the fourth row should be selected"); + + await testUtils.fields.triggerKeydown($(document.activeElement), 'tab'); + assert.hasClass(list.$('.o_data_row:eq(1)'), 'o_selected_row', + "the second row should be selected again"); + + await testUtils.fields.triggerKeydown($(document.activeElement), 'tab'); + assert.hasClass(list.$('.o_data_row:eq(3)'), 'o_selected_row', + "the fourth row should be selected again"); + + await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_data_cell:eq(0)')); + assert.containsNone(list, '.o_data_cell input.o_field_widget', "no row should be editable anymore"); + await testUtils.dom.click(list.$('.o_data_row:eq(2) .o_data_cell:eq(0)')); + + list.destroy(); + }); + + QUnit.test('editable readonly list view: single edition', async function (assert) { + assert.expect(2); + + const list = await createView({ + arch: ` + + + `, + data: this.data, + model: 'foo', + View: ListView, + }); + + // select a record + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input')); + + // edit a field (invalid input) + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)')); + await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), ""); + await testUtils.dom.click('body'); + + assert.hasClass(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)'), 'o_invalid_cell', + "like in standard single edit, field is marked as invalid"); + + // edit a field + await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), "bar"); + await testUtils.fields.triggerKeydown(list.$('.o_field_widget[name=foo]'), 'enter'); + assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "bar", + "the first row should be updated"); + + list.destroy(); + }); + + QUnit.test('editable readonly list view: multi edition', async function (assert) { + assert.expect(14); + + const list = await createView({ + arch: + ` + + + `, + data: this.data, + mockRPC: function (route, args) { + assert.step(args.method || route); + if (args.method === 'write') { + assert.deepEqual(args.args, [[1, 2], { int_field: 666 }], + "should write on multi records"); + } else if (args.method === 'read') { + if (args.args[0].length !== 1) { + assert.deepEqual(args.args, [[1, 2], ['foo', 'int_field']], + "should batch the read"); + } + } + return this._super.apply(this, arguments); + }, + model: 'foo', + View: ListView, + }); + + assert.verifySteps(['/web/dataset/search_read']); + + // select two records + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input')); + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input')); + + // edit a field + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')); + await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666); + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(0)')); + + assert.containsOnce(document.body, '.modal', + "modal appears when switching cells"); + + await testUtils.dom.click($('.modal .btn:contains(Cancel)')); + + assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), 'yop10', + "changes have been discarded and row is back to readonly"); + + await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)')); + await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666); + await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell:eq(0)')); + + assert.containsOnce(document.body, '.modal', + "there should be an opened modal"); + assert.ok($('.modal').text().includes('those 2 records'), + "the number of records should be correctly displayed"); + + await testUtils.dom.click($('.modal .btn-primary')); + + assert.verifySteps(['write', 'read']); + assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "yop666", + "the first row should be updated"); + assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "blip666", + "the second row should be updated"); + assert.containsNone(list, '.o_data_cell input.o_field_widget', + "no field should be editable anymore"); + + list.destroy(); + }); + QUnit.test('list grouped by date:month', async function (assert) { assert.expect(1);