[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
This commit is contained in:
Julien Mougenot
2019-09-25 07:20:36 +00:00
committed by Aaron Bohy
parent 3023cd24af
commit 3147e5fada
9 changed files with 583 additions and 287 deletions
@@ -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);
},
});
@@ -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/>');
},
/**
@@ -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',
});
},
});
@@ -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 () => {
@@ -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;
@@ -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 });
+85 -91
View File
@@ -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
}
}
@@ -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;
+248
View File
@@ -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);