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);