diff options
| author | Julien Mougenot <jum@odoo.com> | 2019-09-18 10:33:26 +0000 |
|---|---|---|
| committer | Aaron Bohy <aab@odoo.com> | 2019-09-25 07:20:36 +0000 |
| commit | 3147e5fada058ec676183b3136145ffe824a9044 (patch) | |
| tree | ef2ee8476a203865b6065105ad15dfa572a5875f | |
| parent | 3023cd24af35bf63f1c962ec51183a20f9ca80e0 (diff) | |
[IMP] web,*: editable readonly list views
*:board,hr_skills
Hold on tight, this is a tricky one.
Allows *any* readonly list view (only plain lists, not x2manies) to
become partially editable when checking records.
This means that once checked, a record line acts just like it was
in an editable list. You can click on it, edit any of its values as long as
they're not constrained by readonly modifiers, and save them on the fly.
Clicking on an unchecked record will have the same effect as before:
it will open the record, regardless of the other selected records.
You can also check multiple records and edit them all at once by
changing the value of one selected record (just like standard multi edition).
Keyboard navigation is also allowed between selected records (TAB and SHIFT+TAB
to navigate and ENTER to save).
/!\ If a list is only made of records having all of their fields locked by
readonly modifiers, checking a record and clicking on it will do absolutely
nothing. This is to keep consistency with the rest of the specs.
Task 1967602
| -rw-r--r-- | addons/board/static/tests/dashboard_tests.js | 1 | ||||
| -rw-r--r-- | addons/hr_skills/static/src/js/resume_widget.js | 19 | ||||
| -rw-r--r-- | addons/web/static/src/js/fields/relational_fields.js | 4 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/list/list_controller.js | 41 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/list/list_editable_renderer.js | 367 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/list/list_renderer.js | 6 | ||||
| -rw-r--r-- | addons/web/static/src/scss/list_view.scss | 172 | ||||
| -rw-r--r-- | addons/web/static/src/scss/list_view_extra.scss | 2 | ||||
| -rw-r--r-- | addons/web/static/tests/views/list_tests.js | 248 |
9 files changed, 578 insertions, 282 deletions
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 $('<div>'); + return $('<thead/>'); }, /** + * Renders a empty footer + * * @override * @private - * Don't render any footer */ _renderFooter: function () { - return $('<div>'); + return $('<tfoot/>'); }, /** 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); }); @@ -547,14 +554,6 @@ ListRenderer.include({ 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. * @@ -568,6 +567,14 @@ ListRenderer.include({ } }, /** + * 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 () { @@ -621,6 +628,19 @@ ListRenderer.include({ 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 * field types (e.g. date and numeric fields). By default, the remaining @@ -670,29 +690,40 @@ ListRenderer.include({ return fixedWidths[type] || '1'; }, /** - * @private - * - * @returns {integer} - */ - _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). + * Returns the nearest editable row starting from a given table row. + * If the list is grouped, jumps to the next unfolded group * - * @param {jQuery} $row this row must be inside a group - * @returns {integer|null} + * @private + * @param {jQuery} $row starting point + * @param {boolean} next whether the requested row should be the next or the previous one + * @return {jQuery|null} */ - _getNextGroupFirstRowIndex: function ($row) { - var $nextBody = $row.closest('tbody').next(); - while ($nextBody.length && !$nextBody.find('.o_data_row').length) { - $nextBody = $nextBody.next(); - } - if ($nextBody.find('.o_data_row').length) { - return $nextBody.find('.o_data_row:first').prop('rowIndex') - 1; + _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 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. + * + * @param {string} recordID + * @returns {boolean} + */ + _isRecordEditable: function (recordID) { + return this.editable || this.selection.includes(recordID); + }, + /** + * Moves to the next row in the list * * @private + * @params {Object} [options] see @_moveToSideLine */ - _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}); - }); + _moveToNextLine: function (options) { + this._moveToSideLine(true, options); }, /** - * 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 + * 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 line (of the next group in the grouped case). + * 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,108 +180,103 @@ 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 { - display: inline; - } + .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.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_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_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 { - 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_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); - } + } + > .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 } - > 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_dropdown_button, .o_datepicker_button { + margin-right: 5px; + @include o-position-absolute(0, 0); } - > .o_row_handle { - visibility: hidden; // hide sequence when editing + } + > 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_field_x2many_list_row_add, .o_group_field_row_add { 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: '<tree editable="bottom">' + + '<field name="foo" required="1"/>' + + '</tree>', + 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: ` + <tree> + <field name="foo"/> + <field name="int_field"/> + </tree>`, + 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: ` + <tree> + <field name="foo"/> + </tree>`, + 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: ` + <tree> + <field name="foo" required="1"/> + </tree>`, + 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: + `<tree> + <field name="foo"/> + <field name="int_field"/> + </tree>`, + 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); |
