[IMP] web: list: editable grouped list views

This rev. enables the editable feature in grouped list views.

Part of task 1915702

Co-authored-by: Aaron Bohy <aab@odoo.com>
Co-authored-by: Martin Geubelle <mge@odoo.com>
This commit is contained in:
Ravi Gadhia
2019-04-02 11:55:57 +00:00
committed by Aaron Bohy
co-authored by Aaron Bohy Martin Geubelle
parent 55676110c8
commit 234d92c4f0
11 changed files with 1292 additions and 282 deletions
@@ -1240,8 +1240,7 @@ var FieldX2Many = AbstractField.extend({
_onEditLine: function (ev) {
ev.stopPropagation();
this.trigger_up('edited_list', { id: this.value.id });
var editedRecord = this.value.data[ev.data.index];
this.renderer.setRowMode(editedRecord.id, 'edit')
this.renderer.setRowMode(ev.data.recordId, 'edit')
.then(ev.data.onSuccess);
},
/**
@@ -88,8 +88,10 @@ var concurrency = require('web.concurrency');
var Context = require('web.Context');
var core = require('web.core');
var Domain = require('web.Domain');
var fieldUtils = require('web.field_utils');
var session = require('web.session');
var utils = require('web.utils');
var viewUtils = require('web.viewUtils');
var _t = core._t;
@@ -178,7 +180,7 @@ var BasicModel = AbstractModel.extend({
addDefaultRecord: function (listID, options) {
var self = this;
var list = this.localData[listID];
var context = this._getContext(list);
var context = _.extend({}, this._getDefaultContext(list), this._getContext(list));
var position = (options && options.position) || 'top';
var params = {
@@ -3332,6 +3334,27 @@ var BasicModel = AbstractModel.extend({
});
return toFetch;
},
/**
* Given a dataPoint of type list (that may be a group), returns an object
* with 'default_' keys to be used to create new records in that group.
*
* @private
* @param {Object} dataPoint
* @returns {Object}
*/
_getDefaultContext: function (dataPoint) {
var defaultContext = {};
while (dataPoint.parentID) {
var parent = this.localData[dataPoint.parentID];
var groupByField = parent.groupedBy[0].split(':')[0];
var value = viewUtils.getGroupValue(dataPoint, groupByField);
if (value) {
defaultContext['default_' + groupByField] = value;
}
dataPoint = parent;
}
return defaultContext;
},
/**
* Some records are associated to a/some domain(s). This method allows to
* retrieve them, evaluated.
@@ -91,8 +91,7 @@ var BasicRenderer = AbstractRenderer.extend({
*/
confirmChange: function (state, id, fields, ev) {
this.state = state;
var record = state.id === id ? state : _.findWhere(state.data, {id: id});
var record = this._getRecord(id);
if (!record) {
return this._render().then(_.constant([]));
}
@@ -350,6 +349,19 @@ var BasicRenderer = AbstractRenderer.extend({
_getModifiersData: function (node) {
return _.findWhere(this.allModifiersData, {node: node});
},
/**
* This function is meant to be overriden in renderers. It takes a dataPoint
* id (for a dataPoint of type record), and should return the corresponding
* dataPoint.
*
* @abstract
* @private
* @param {string} [recordId]
* @returns {Object|null}
*/
_getRecord: function (recordId) {
return null;
},
/**
* @private
* @param {jQueryElement} $el
@@ -345,6 +345,13 @@ var FormRenderer = BasicRenderer.extend({
}
return idForLabel;
},
/**
* @override
* @private
*/
_getRecord: function (recordId) {
return this.state.id === recordId ? this.state : null;
},
/**
* @override
* @private
@@ -24,6 +24,7 @@ var ListController = BasicController.extend({
*/
buttons_template: 'ListView.buttons',
custom_events: _.extend({}, BasicController.prototype.custom_events, {
activate_next_widget: '_onActivateNextWidget',
add_record: '_onAddRecord',
button_clicked: '_onButtonClicked',
edit_line: '_onEditLine',
@@ -128,8 +129,8 @@ var ListController = BasicController.extend({
this._assignCreateKeyboardBehavior(this.$buttons.find('.o_list_button_add'));
this.$buttons.find('.o_list_button_add').tooltip({
delay: {show: 200, hide:0},
title: function(){
delay: {show: 200, hide: 0},
title: function () {
return qweb.render('CreateButton.tooltip');
},
trigger: 'manual',
@@ -230,17 +231,19 @@ var ListController = BasicController.extend({
}
},
/**
* Adds a record to the list.
* Adds a new record to the a dataPoint of type 'list'.
* Disables the buttons to prevent concurrent record creation or edition.
*
* @todo make record creation a basic controller feature
* @private
* @param {string} dataPointId a dataPoint of type 'list' (may be grouped)
* @return {Promise}
*/
_addRecord: function () {
_addRecord: function (dataPointId) {
var self = this;
this._disableButtons();
return this.renderer.unselectRow().then(function () {
return self.model.addDefaultRecord(self.handle, {
return self.model.addDefaultRecord(dataPointId, {
position: self.editable,
});
}).then(function (recordID) {
@@ -365,6 +368,19 @@ var ListController = BasicController.extend({
_toggleArchiveState: function (archive) {
this._archive(this.selectedRecords, archive);
},
/**
* Hide the create button in non-empty grouped editable list views, as an
* 'Add an item' link is available in each group.
*
* @private
*/
_toggleCreateButton: function () {
if (this.$buttons) {
var state = this.model.get(this.handle);
var createHidden = this.editable && state.groupedBy.length && state.data.length;
this.$buttons.find('.o_list_button_add').toggleClass('o_hidden', !!createHidden);
}
},
/**
* Display the sidebar (the 'action' menu in the control panel) if we have
* some selected records.
@@ -380,8 +396,9 @@ var ListController = BasicController.extend({
*/
_update: function () {
var self = this;
return this._super.apply(this, arguments).then(function() {
return this._super.apply(this, arguments).then(function () {
self._toggleSidebar();
self._toggleCreateButton();
});
},
/**
@@ -401,15 +418,29 @@ var ListController = BasicController.extend({
//--------------------------------------------------------------------------
/**
* Add a record to the list
* Triggered when navigating with TAB, when the end of the list has been
* reached. Go back to the first row in that case.
*
* @private
* @param {OdooEvent} ev
*/
_onActivateNextWidget: function (ev) {
ev.stopPropagation();
this.renderer.editFirstRecord();
},
/**
* Add a record to the list
*
* @private
* @param {OdooEvent} ev
* @param {string} [ev.data.groupId=this.handle] the id of a dataPoint of
* type list to which the record must be added (default: main list)
*/
_onAddRecord: function (ev) {
ev.stopPropagation();
var dataPointId = ev.data.groupId || this.handle;
if (this.activeActions.create) {
this._addRecord();
this._addRecord(dataPointId);
} else if (ev.data.onFail) {
ev.data.onFail();
}
@@ -441,7 +472,7 @@ var ListController = BasicController.extend({
}
var state = this.model.get(this.handle, {raw: true});
if (this.editable && !state.groupedBy.length) {
this._addRecord();
this._addRecord(this.handle);
} else {
this.trigger_up('switch_view', {view_type: 'form', res_id: undefined});
}
@@ -473,9 +504,7 @@ var ListController = BasicController.extend({
ev.stopPropagation();
this.trigger_up('mutexify', {
action: function () {
var record = self.model.get(self.handle);
var editedRecord = record.data[ev.data.index];
self._setMode('edit', editedRecord.id)
self._setMode('edit', ev.data.recordId)
.then(ev.data.onSuccess);
},
});
@@ -24,6 +24,7 @@ ListRenderer.include({
}),
events: _.extend({}, ListRenderer.prototype.events, {
'click .o_field_x2many_list_row_add a': '_onAddRecord',
'click .o_group_field_row_add a': '_onAddRecordToGroup',
'keydown .o_field_x2many_list_row_add a': '_onKeyDownAddRecord',
'click tbody td.o_data_cell': '_onCellClick',
'click tbody tr:not(.o_data_row)': '_onEmptyRowClick',
@@ -34,15 +35,19 @@ ListRenderer.include({
* @override
* @param {Object} params
* @param {boolean} params.addCreateLine
* @param {boolean} params.addCreateLineInGroups
* @param {boolean} params.addTrashIcon
* @param {boolean} params.isMany2Many
*/
init: function (parent, state, params) {
var self = this;
this._super.apply(this, arguments);
// if addCreateLine is true, the renderer will add a 'Add a line' link
// at the bottom of the list view
// if addCreateLine (resp. addCreateLineInGroups) is true, the renderer
// will add a 'Add a line' link at the bottom of the list view (resp.
// at the bottom of each group)
this.addCreateLine = params.addCreateLine;
this.addCreateLineInGroups = params.addCreateLineInGroups;
// Controls allow overriding "add a line" by custom controls.
@@ -90,19 +95,14 @@ ListRenderer.include({
this.currentRow = null;
this.currentFieldIndex = null;
this.allRecordsIds = null; // flat array of records ids used by navigation
},
/**
* @override
* @returns {Promise}
*/
start: function () {
// deliberately use the 'editable' attribute instead of '_isEditable'
// function, because the groupBy must not be taken into account to
// enable the '_onWindowClicked' handler (otherwise, an editable grouped
// list which is reloaded without groupBy wouldn't have this handler
// bound, and edited rows couldn't be left by clicking outside the list)
if (this.editable) {
this.$el.css({height: '100%'}); // seems useless: to remove in master
core.bus.on('click', this, this._onWindowClicked.bind(this));
}
return this._super();
@@ -139,15 +139,13 @@ ListRenderer.include({
*
* @override
*/
confirmChange: function (state, id) {
confirmChange: function (state, recordID) {
var self = this;
return this._super.apply(this, arguments).then(function (widgets) {
if (widgets.length) {
var rowIndex = _.findIndex(state.data, function (r) {
return r.id === id;
});
var $row = self.$('.o_data_row:nth(' + rowIndex + ')');
self._setDecorationClasses(state.data[rowIndex], $row);
var $row = self._getRow(recordID);
var record = self._getRecord(recordID);
self._setDecorationClasses(record, $row);
self._updateFooter();
}
return widgets;
@@ -177,83 +175,96 @@ ListRenderer.include({
confirmUpdate: function (state, id, fields, ev) {
var self = this;
// store the cursor position to restore it once potential onchanges have
// been applied
var currentRowID, currentWidget, focusedElement, selectionRange;
if (this.currentRow !== null) {
currentRowID = this.state.data[this.currentRow].id;
currentWidget = this.allFieldWidgets[currentRowID][this.currentFieldIndex];
if (currentWidget) {
focusedElement = currentWidget.getFocusableElement().get(0);
if (currentWidget.formatType !== 'boolean') {
selectionRange = dom.getSelectionRange(focusedElement);
}
}
}
var oldData = this.state.data;
this.state = state;
return this.confirmChange(state, id, fields, ev).then(function () {
// If no record with 'id' can be found in the state, the
// confirmChange method will have rerendered the whole view already,
// so no further work is necessary.
var record = _.findWhere(state.data, {id: id});
var record = self._getRecord(id);
if (!record) {
return;
}
var oldRowIndex = _.findIndex(oldData, {id: id});
var $row = self.$('.o_data_row:nth(' + oldRowIndex + ')');
$row.nextAll('.o_data_row').remove();
$row.prevAll().remove();
_.each(oldData, function (rec) {
if (rec.id !== id) {
self._destroyFieldWidgets(rec.id);
}
});
var newRowIndex = _.findIndex(state.data, {id: id});
var $lastRow = $row;
var defs = [];
self.defs = defs;
state.data.forEach(function (record, index) {
if (index === newRowIndex) {
return;
}
// FIXME: as we are manipulating the DOM directly, it will
// flicker if there is an async widget in the row
var $newRow = self._renderRow(record);
if (index < newRowIndex) {
$newRow.insertBefore($row);
} else {
$newRow.insertAfter($lastRow);
$lastRow = $newRow;
}
});
// re-render whole body (outside the dom)
self.defs = [];
var $newBody = self._renderBody();
var defs = self.defs;
delete self.defs;
return Promise.all(defs).then(function () {
// update registered modifiers to edit 'mode' because the call to
// _renderBody set baseModeByRecord as 'readonly'
_.each(self.columns, function (node) {
self._registerModifiers(node, record, null, {mode: 'edit'});
});
// store the selection range to restore it once the table will
// be re-rendered, and the current cell re-selected
var currentRowID, currentWidget, focusedElement, selectionRange;
if (self.currentRow !== null) {
self.currentRow = newRowIndex;
return self._selectCell(newRowIndex, self.currentFieldIndex, {force: true});
}
}).then(function () {
// restore the cursor position
currentRowID = self.state.data[newRowIndex].id;
currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex];
if (currentWidget) {
focusedElement = currentWidget.getFocusableElement().get(0);
if (selectionRange) {
dom.setSelectionRange(focusedElement, selectionRange);
currentRowID = self._getRecordID(self.currentRow);
currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex];
if (currentWidget) {
focusedElement = currentWidget.getFocusableElement().get(0);
if (currentWidget.formatType !== 'boolean') {
selectionRange = dom.getSelectionRange(focusedElement);
}
}
}
// remove all rows except the one being edited, and insert rows
// of the re-rendered body before and after it
var $editedRow = self._getRow(id);
$editedRow.nextAll().remove();
$editedRow.prevAll().remove();
var $newRow = $newBody.find('.o_data_row[data-id="' + id + '"]');
var $tbody = self.$('tbody');
$newRow.prevAll().each(function (i, prevRow) {
$tbody.prepend($(prevRow));
});
$newRow.nextAll().each(function (i, nextRow) {
$tbody.append($(nextRow));
});
if (self.currentRow !== null) {
var newRowIndex = $editedRow.prop('rowIndex') - 1;
self.currentRow = newRowIndex;
return self._selectCell(newRowIndex, self.currentFieldIndex, {force: true})
.then(function () {
// restore the selection range
currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex];
if (currentWidget) {
focusedElement = currentWidget.getFocusableElement().get(0);
if (selectionRange) {
dom.setSelectionRange(focusedElement, selectionRange);
}
}
});
}
});
});
},
/**
* Edit the first record in the list
*/
editFirstRecord: function () {
this._selectCell(this._getFirstDataRowIndex(), 0);
},
/**
* Edit a given record in the list
*
* @param {string} recordID
*/
editRecord: function (recordID) {
var rowIndex = _.findIndex(this.state.data, {id: recordID});
var $row = this._getRow(recordID);
var rowIndex = $row.prop('rowIndex') - 1;
this._selectCell(rowIndex, 0);
},
/**
@@ -264,7 +275,7 @@ ListRenderer.include({
*/
getEditableRecordID: function () {
if (this.currentRow !== null) {
return this.state.data[this.currentRow].id;
return this._getRecordID(this.currentRow);
}
return null;
},
@@ -276,19 +287,18 @@ ListRenderer.include({
* @param {string} recordID
*/
removeLine: function (state, recordID) {
var rowIndex = _.findIndex(this.state.data, {id: recordID});
this.state = state;
if (rowIndex === -1) {
var $row = this._getRow(recordID);
if ($row.length === 0) {
return;
}
if (rowIndex === this.currentRow) {
if ($row.prop('rowIndex') - 1 === this.currentRow) {
this.currentRow = null;
}
// destroy widgets first
this._destroyFieldWidgets(recordID);
// remove the row
var $row = this.$('.o_data_row:nth(' + rowIndex + ')');
if (this.state.count >= 4) {
$row.remove();
} else {
@@ -305,35 +315,14 @@ ListRenderer.include({
*/
setRowMode: function (recordID, mode) {
var self = this;
// find the record and its row index (handles ungrouped and grouped cases
// as even if the grouped list doesn't support edition, it may contain
// a widget allowing the edition in readonly (e.g. priority), so it
// should be able to update a record as well)
var record;
var rowIndex;
if (this.state.groupedBy.length) {
rowIndex = -1;
var count = 0;
utils.traverse_records(this.state, function (r) {
if (r.id === recordID) {
record = r;
rowIndex = count;
}
count++;
});
} else {
rowIndex = _.findIndex(this.state.data, {id: recordID});
record = this.state.data[rowIndex];
}
if (rowIndex < 0) {
var record = self._getRecord(recordID);
if (!record) {
return Promise.resolve();
}
var editMode = (mode === 'edit');
this.currentRow = editMode ? rowIndex : null;
var $row = this.$('.o_data_row:nth(' + rowIndex + ')');
var editMode = (mode === 'edit');
var $row = this._getRow(recordID);
this.currentRow = editMode ? $row.prop('rowIndex') - 1 : null;
var $tds = $row.children('.o_data_cell');
var oldWidgets = _.clone(this.allFieldWidgets[record.id]);
@@ -416,14 +405,13 @@ ListRenderer.include({
if (this.currentRow === null) {
return Promise.resolve();
}
var record = this.state.data[this.currentRow];
var recordWidgets = this.allFieldWidgets[record.id];
var recordID = this._getRecordID(this.currentRow);
var recordWidgets = this.allFieldWidgets[recordID];
toggleWidgets(true);
var prom = new Promise(function (resolve, reject) {
self.trigger_up('save_line', {
recordID: record.id,
recordID: recordID,
onSuccess: resolve,
onFailure: reject,
});
@@ -458,6 +446,30 @@ ListRenderer.include({
delete this.allFieldWidgets[recordID];
}
},
/**
*
* @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).
*
* @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();
}
if ($nextBody.find('.o_data_row').length) {
return $nextBody.find('.o_data_row:first').prop('rowIndex') - 1;
}
return null;
},
/**
* Returns the current number of columns. The editable renderer may add a
* trash icon on the right of a record, so we need to take this into account
@@ -473,49 +485,170 @@ ListRenderer.include({
return n;
},
/**
* Returns true iff the list is editable, i.e. if it isn't grouped and if
* the editable attribute is set on the root node of its arch.
* Traverse this.state to find and return the record with given dataPoint id
* (for grouped list views, the record could be deep down in state tree).
*
* @override
* @private
* @returns {boolean}
*/
_isEditable: function () {
return !this.state.groupedBy.length && this.editable;
_getRecord: function (recordId) {
var record;
utils.traverse_records(this.state, function (r) {
if (r.id === recordId) {
record = r;
}
});
return record;
},
/**
* Move the cursor on the end of the previous line, if possible.
* If there is no previous line, then we create a new record.
* Retrieve the record dataPoint id from a rowIndex as the row DOM element
* stores the record id in data.
*
* @private
* @param {integer} rowIndex
* @returns {string} record dataPoint id
*/
_getRecordID: function (rowIndex) {
var $tr = this.$('table.o_list_view > tbody tr').eq(rowIndex);
return $tr.data('id');
},
/**
* Return the jQuery tr element corresponding to the given record dataPoint
* id.
*
* @private
* @param {string} [recordId]
* @returns {jQueryElement}
*/
_getRow: function (recordId) {
return this.$('.o_data_row[data-id="' + recordId + '"]');
},
/**
* Move the cursor on the end of the previous line (or of the last line if
* we are on the first one).
*
* @private
*/
_moveToPreviousLine: function () {
if (this.currentRow > 0) {
this._selectCell(this.currentRow - 1, this.columns.length - 1);
} else {
this.unselectRow().then(this.trigger_up.bind(this, 'add_record'));
var self = this;
if (!this.allRecordsIds) {
// compute the flat array of all records ids only once
this.allRecordIds = [];
utils.traverse_records(this.state, function (data) {
self.allRecordIds.push(data.id);
});
}
var curRecordId = this._getRecordID(this.currentRow);
var curRecordIndex = this.allRecordIds.indexOf(curRecordId);
var prevRecordIndex = curRecordIndex === 0 ? this.allRecordIds.length - 1 : curRecordIndex - 1;
this.commitChanges(curRecordId).then(function () {
var $prevRow = self._getRow(self.allRecordIds[prevRecordIndex]);
var prevRowIndex = $prevRow.prop('rowIndex') - 1;
self._selectCell(prevRowIndex, self.columns.length - 1);
});
},
/**
* Move the cursor on the beginning of the next line, if possible.
* If there is no next line, then we create a new record.
* 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).
*
* @private
* @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
* under some conditions)
*/
_moveToNextLine: function () {
_moveToNextLine: function (options) {
var self = this;
var record = this.state.data[this.currentRow];
this.commitChanges(record.id).then(function () {
var fieldNames = self.canBeSaved(record.id);
if (fieldNames.length) {
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;
}
if (self.currentRow < self.state.data.length - 1) {
self._selectCell(self.currentRow + 1, 0);
// compute the index of the next (record) row to select, if any
var nextRowIndex = null;
var groupId;
if (!self.isGrouped) {
// ungrouped case
if (self.currentRow < self.state.data.length - 1) {
nextRowIndex = self.currentRow + 1;
} else if (!options.forceCreate && !record.isDirty()) {
self.trigger_up('discard_changes', {
recordID: recordID,
onSuccess: function () {
self.trigger_up('activate_next_widget');
},
});
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") {
// 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('groupID');
} 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', {
recordID: recordID,
onSuccess: function () {
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);
} else {
// we were in the last group, so go back to the top
self._selectCell(self._getFirstDataRowIndex(), 0, {});
}
},
});
return;
}
} else {
// there is no 'Add a line' row (i.e. the create feature is disabled), or the
// list is editable="top", we focus the first record of the next group if any
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();
}
}
}
// 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', {
onFail: self._selectCell.bind(self, 0, 0, {}),
groupId: groupId,
onFail: self._selectCell.bind(self, self._getFirstDataRowIndex(), 0, {}),
});
});
}
@@ -530,6 +663,31 @@ ListRenderer.include({
this.currentFieldIndex = null;
return this._super.apply(this, arguments);
},
/**
* Override to add the 'Add an item' link to the end of last-level opened
* groups.
*
* @override
* @private
*/
_renderGroup: function (group) {
var result = this._super.apply(this, arguments);
if (!group.groupedBy.length && this.addCreateLineInGroups) {
var $groupBody = result[0];
var $a = $('<a href="#" role="button">')
.text(_t("Add a line"))
.data('groupID', group.id);
var $td = $('<td>')
.attr('colspan', this._getNumberOfCols())
.addClass('o_group_field_row_add')
.append($a);
var $tr = $('<tr>', {class: 'o_add_record_row'})
.data('groupID', group.id)
.append($td);
$groupBody.append($tr.prepend($('<td>').html('&nbsp;')));
}
return result;
},
/**
* The renderer needs to support reordering lines. This is only active in
* edit mode. The handleField attribute is set when there is a sequence
@@ -618,8 +776,9 @@ ListRenderer.include({
_renderView: function () {
var self = this;
this.currentRow = null;
this.allRecordsIds = null;
return this._super.apply(this, arguments).then(function () {
if (self._isEditable()) {
if (self.editable) {
self.$('table').addClass('o_editable_list');
}
});
@@ -635,7 +794,7 @@ ListRenderer.include({
var self = this;
var movedRecordID = ui.item.data('id');
var rows = this.state.data;
var row = _.findWhere(rows, {id: movedRecordID});
var row = self._getRecord(movedRecordID);
var index0 = rows.indexOf(row);
var index1 = ui.item.index();
var lower = Math.min(index0, index1);
@@ -714,7 +873,8 @@ ListRenderer.include({
// Select the row then activate the widget in the correct cell
var self = this;
return this._selectRow(rowIndex).then(function () {
var record = self.state.data[rowIndex];
var recordID = self._getRecordID(rowIndex);
var record = self._getRecord(recordID);
if (fieldIndex >= (self.allFieldWidgets[record.id] || []).length) {
return Promise.reject();
}
@@ -746,11 +906,11 @@ ListRenderer.include({
if (rowIndex === this.currentRow) {
return Promise.resolve();
}
var recordId = this._getRecordID(rowIndex);
// To select a row, the currently selected one must be unselected first
var self = this;
return this.unselectRow().then(function () {
if (self.state.data.length <= rowIndex) {
if (!recordId) {
// The row to selected doesn't exist anymore (probably because
// an onchange triggered when unselecting the previous one
// removes rows)
@@ -759,7 +919,7 @@ ListRenderer.include({
// Notify the controller we want to make a record editable
return new Promise(function (resolve) {
self.trigger_up('edit_line', {
index: rowIndex,
recordId: recordId,
onSuccess: resolve,
});
});
@@ -770,10 +930,31 @@ ListRenderer.include({
// Handlers
//--------------------------------------------------------------------------
/**
* This method is called when we click on the 'Add a line' button in a groupby
* list view.
*
* @param {MouseEvent} ev
*/
_onAddRecordToGroup: function (ev) {
ev.preventDefault();
// we don't want the click to cause other effects, such as unselecting
// the row that we are creating, because it counts as a click on a tr
ev.stopPropagation();
var self = this;
var groupId = $(ev.target).data('groupID');
this.unselectRow().then(function () {
self.trigger_up('add_record', {
groupId: groupId,
});
});
},
/**
* This method is called when we click on the 'Add a line' button in a sub
* list such as a one2many in a form view.
*
* @private
* @param {MouseEvent} ev
*/
_onAddRecord: function (ev) {
@@ -799,12 +980,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._isEditable() || $(event.target).prop('special_click')) {
if (!this.editable || $(event.target).prop('special_click')) {
return;
}
var $td = $(event.currentTarget);
var $tr = $td.parent();
var rowIndex = this.$('.o_data_row').index($tr);
var rowIndex = $tr.prop('rowIndex') - 1;
var fieldIndex = Math.max($tr.find('.o_data_cell').not('.o_list_button').index($td), 0);
this._selectCell(rowIndex, fieldIndex, {event: event});
},
@@ -822,11 +1003,11 @@ ListRenderer.include({
_onFooterClick: function () {
this.unselectRow();
},
/**
* @param {KeyDownEvent} e
* @private
*/
_onKeyDownAddRecord: function(e) {
/**
* @private
* @param {KeyDownEvent} e
*/
_onKeyDownAddRecord: function (e) {
switch(e.keyCode) {
case $.ui.keyCode.ENTER:
e.stopPropagation();
@@ -842,8 +1023,8 @@ ListRenderer.include({
* @returns {Class} Widget returns last widget
*/
_getLastWidget: function () {
var record = this.state.data[this.currentRow];
var recordWidgets = this.allFieldWidgets[record.id];
var recordID = this._getRecordID(this.currentRow);
var recordWidgets = this.allFieldWidgets[recordID];
var lastWidget = _.chain(recordWidgets).filter(function (widget) {
var isLast =
widget.$el.is(':visible') &&
@@ -856,7 +1037,6 @@ ListRenderer.include({
}).last().value();
return lastWidget;
},
/**
* Handles the keyboard navigation according to events triggered by field
* widgets.
@@ -885,23 +1065,10 @@ ListRenderer.include({
}
break;
case 'next':
// When navigating with the keyboard, we want to get out of the list editable if the
// entire line is left unmodified and we are on the next line.
var column = this.columns[this.currentFieldIndex];
var lastWidget = this._getLastWidget();
if (column.attrs.name === lastWidget.name) {
if (this.currentRow + 1 < this.state.data.length) {
this._selectCell(this.currentRow+1, 0, {wrap:false})
.guardedCatch(this._moveToNextLine.bind(this));
} else {
var currentRowData = this.state.data[this.currentRow];
if (currentRowData.isDirty(currentRowData.id)) {
this._moveToNextLine();
}
else {
this.trigger_up('activate_next_widget');
}
}
this._moveToNextLine();
} else {
if (this.currentFieldIndex + 1 < this.columns.length) {
this._selectCell(this.currentRow, this.currentFieldIndex + 1, {wrap: false})
@@ -912,7 +1079,7 @@ ListRenderer.include({
}
break;
case 'next_line':
this._moveToNextLine();
this._moveToNextLine({forceCreate: true});
break;
case 'cancel':
// stop the original event (typically an ESCAPE keydown), to
@@ -922,7 +1089,7 @@ ListRenderer.include({
this.trigger_up('discard_changes', {
recordID: ev.target.dataPointID,
onSuccess: function () {
self.$('.o_field_x2many_list_row_add a:first').focus();
self.$('.o_field_x2many_list_row_add a:first').focus(); // FIXME
}
});
break;
@@ -955,7 +1122,7 @@ ListRenderer.include({
* @private
*/
_onRowClicked: function () {
if (!this._isEditable()) {
if (!this.editable) {
this._super.apply(this, arguments);
}
},
@@ -471,6 +471,27 @@ var ListRenderer = BasicRenderer.extend({
.append($th)
.append($cells);
},
/**
* Render the content of a given opened group.
*
* @private
* @param {Object} group
* @param {integer} groupLevel the nesting level (0 for root groups)
* @returns {jQueryElement} a <tr> element
*/
_renderGroup: function (group, groupLevel) {
var self = this;
if (group.groupedBy.length) {
// the opened group contains subgroups
return this._renderGroups(group.data, groupLevel + 1);
} else {
// the opened group contains records
var $records = _.map(group.data, function (record) {
return self._renderRow(record);
});
return [$('<tbody>').append($records)];
}
},
/**
* Render all groups in the view. We assume that the view is in grouped
* mode.
@@ -495,17 +516,7 @@ var ListRenderer = BasicRenderer.extend({
$tbody.append(self._renderGroupRow(group, groupLevel));
if (group.data.length) {
result.push($tbody);
// render an opened group
if (group.groupedBy.length) {
// the opened group contains subgroups
result = result.concat(self._renderGroups(group.data, groupLevel + 1));
} else {
// the opened group contains records
var $records = _.map(group.data, function (record) {
return self._renderRow(record);
});
result.push($('<tbody>').append($records));
}
result = result.concat(self._renderGroup(group, groupLevel));
$tbody = null;
}
});
@@ -594,7 +605,7 @@ var ListRenderer = BasicRenderer.extend({
});
var $tr = $('<tr/>', { class: 'o_data_row' })
.data('id', record.id)
.attr('data-id', record.id)
.append($cells);
if (this.hasSelectors) {
$tr.prepend(this._renderSelector('td', !record.res_id));
@@ -50,6 +50,8 @@ var ListView = BasicView.extend({
'hasSelectors' in params ? params.hasSelectors : true;
this.rendererParams.editable = params.readonly ? false : this.arch.attrs.editable;
this.rendererParams.selectedRecords = selectedRecords;
this.rendererParams.addCreateLine = false;
this.rendererParams.addCreateLineInGroups = this.rendererParams.editable && this.controllerParams.activeActions.create;
this.loadParams.limit = this.loadParams.limit || 80;
this.loadParams.type = 'list';
+7 -2
View File
@@ -19,10 +19,15 @@ var viewUtils = {
case 'many2one':
return group.res_id || false;
case 'selection':
var descriptor = _.findWhere(groupedByField.selection, group.value);
var descriptor = _.find(groupedByField.selection, function (option) {
return option[1] === group.value;
});
return descriptor && descriptor[0];
default:
case 'char':
case 'boolean':
return group.value;
default:
return false; // other field types are not handled
}
},
/**
@@ -3022,11 +3022,11 @@ QUnit.module('fields', {}, function () {
},
});
// edit mode, then click on Add an item, then click elsewhere
// edit mode, then click on Add an item, then press enter
await testUtils.form.clickEdit(form);
await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
await testUtils.fields.triggerKeydown(form.$('input[name="turtle_foo"]'), 'enter');
assert.hasClass(form.$('input[name="turtle_foo"]'),'o_field_invalid',
assert.hasClass(form.$('input[name="turtle_foo"]'), 'o_field_invalid',
"input should be marked invalid");
assert.verifySteps(['read', 'default_get']);
form.destroy();
+853 -98
View File
@@ -1722,7 +1722,7 @@ QUnit.module('Views', {
});
QUnit.test('list view, editable, without data', async function (assert) {
assert.expect(13);
assert.expect(12);
this.data.foo.records = [];
@@ -1763,10 +1763,8 @@ QUnit.module('Views', {
assert.containsNone(list, '.o_view_nocontent',
"should not have a no content helper displayed");
assert.containsOnce(list, 'table', "should have rendered a table");
assert.strictEqual(list.$('.o_content').css('height'), list.$('div.table-responsive').css('height'),
"the div for the table should take the full height");
assert.hasClass(list.$('tbody tr:eq(0)'),'o_selected_row',
assert.hasClass(list.$('tbody tr:eq(0)'), 'o_selected_row',
"the date field td should be in edit mode");
assert.strictEqual(list.$('tbody tr:eq(0) td:eq(1)').text().trim(), "",
"the date field td should not have any content");
@@ -3086,100 +3084,6 @@ QUnit.module('Views', {
list.destroy();
});
QUnit.test('grouped list are not editable (ungrouped first)', async function (assert) {
// Editable grouped list views are not supported, so the purpose of this
// test is to check that when a list view is grouped, its editable
// attribute is ignored
// In this test, the view isn't grouped at the beginning, so it is first
// editable, and then it is reloaded with a groupBy and is no longer
// editable
assert.expect(5);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
intercepts: {
switch_view: function (event) {
var resID = event.data.res_id || false;
assert.step('switch view ' + event.data.view_type + ' ' + resID);
},
},
});
await testUtils.dom.click(list.$('.o_data_cell:first'));
assert.verifySteps([], 'no switch view should have been requested');
assert.containsOnce(list, '.o_selected_row',
"a row should be in edition");
await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
// reload with groupBy
await list.reload({groupBy: ['bar']});
// clicking on record should open the form view
await testUtils.dom.click(list.$('.o_group_header:first'));
await testUtils.dom.click(list.$('.o_data_cell:first'));
// clicking on create button should open the form view
await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
assert.verifySteps(['switch view form 1', 'switch view form false'],
'two switch view to form should have been requested');
list.destroy();
});
QUnit.test('grouped list are not editable (grouped first)', async function (assert) {
// Editable grouped list views are not supported, so the purpose of this
// test is to check that when a list view is grouped, its editable
// attribute is ignored
// In this test, the view is grouped at the beginning, so it isn't
// editable, and then it is reloaded with no groupBy and becomes editable
assert.expect(6);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
intercepts: {
switch_view: function (event) {
var resID = event.data.res_id || false;
assert.step('switch view ' + event.data.view_type + ' ' + resID);
},
},
groupBy: ['bar'],
});
// the view being grouped, it is not editable, so clicking on a record
// should open the form view
await testUtils.dom.click(list.$('.o_group_header:first'));
await testUtils.dom.click(list.$('.o_data_cell:first'));
// for the same reason, clicking on 'Create' should open the form view
await testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
assert.verifySteps(['switch view form 1', 'switch view form false'],
"two switch view to form should have been requested");
// reload without groupBy
await list.reload({groupBy: []});
// as the view is no longer grouped, it is editable, so clicking on a
// row should switch it in edition
await testUtils.dom.click(list.$('.o_data_cell:first'));
assert.verifySteps([], "no more switch view should have been requested");
assert.containsOnce(list, '.o_selected_row', "a row should be in edition");
// clicking on the body should leave the edition
await testUtils.dom.click($('body'));
assert.containsNone(list, '.o_selected_row',
"the row should no longer be in edition");
list.destroy();
});
QUnit.test('field values are escaped', async function (assert) {
assert.expect(1);
var value = '<script>throw Error();</script>';
@@ -4160,6 +4064,857 @@ QUnit.module('Views', {
list.destroy();
delete widgetRegistry.map.asyncWidget;
});
QUnit.test('editable grouped lists', async function (assert) {
assert.expect(4);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
// enter edition (grouped case)
await testUtils.dom.click(list.$('.o_data_cell:first'));
assert.containsOnce(list, '.o_selected_row .o_data_cell:first');
// click on the body should leave the edition
await testUtils.dom.click($('body'));
assert.containsNone(list, '.o_selected_row');
// reload without groupBy
await list.reload({groupBy: []});
// enter edition (ungrouped case)
await testUtils.dom.click(list.$('.o_data_cell:first'));
assert.containsOnce(list, '.o_selected_row .o_data_cell:first');
// click on the body should leave the edition
await testUtils.dom.click($('body'));
assert.containsNone(list, '.o_selected_row');
list.destroy();
});
QUnit.test('grouped lists are editable (ungrouped first)', async function (assert) {
assert.expect(2);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
});
// enter edition (ungrouped case)
await testUtils.dom.click(list.$('.o_data_cell:first'));
assert.containsOnce(list, '.o_selected_row .o_data_cell:first');
// reload with groupBy
await list.reload({groupBy: ['bar']});
// open first group
await testUtils.dom.click(list.$('.o_group_header:first'));
// enter edition (grouped case)
await testUtils.dom.click(list.$('.o_data_cell:first'));
assert.containsOnce(list, '.o_selected_row .o_data_cell:first');
list.destroy();
});
QUnit.test('char field edition in editable grouped list', async function (assert) {
assert.expect(2);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom"><field name="foo"/><field name="bar"/></tree>',
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
await testUtils.dom.click(list.$('.o_data_cell:first'));
await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row .o_data_cell:first input[name="foo"]'), 'pla', 'input');
await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
assert.strictEqual(this.data.foo.records[0].foo, 'pla',
"the edition should have been properly saved");
assert.containsOnce(list, '.o_data_row:first:contains(pla)');
list.destroy();
});
QUnit.test('control panel buttons in editable grouped list views', async function (assert) {
assert.expect(2);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
groupBy: ['bar'],
});
assert.isNotVisible(list.$buttons.find('.o_list_button_add'));
// reload without groupBy
await list.reload({groupBy: []});
assert.isVisible(list.$buttons.find('.o_list_button_add'));
list.destroy();
});
QUnit.test('edit a line and discard it in grouped editable', async function (assert) {
assert.expect(5);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo"/><field name="int_field"/></tree>',
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first'));
await testUtils.dom.click(list.$('.o_data_row:nth(2) > td:contains(gnap)'));
assert.ok(list.$('.o_data_row:nth(2)').is('.o_selected_row'),
"third group row should be in edition");
await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
await testUtils.dom.click(list.$('.o_data_row:nth(0) > td:contains(yop)'));
assert.ok(list.$('.o_data_row:eq(0)').is('.o_selected_row'),
"first group row should be in edition");
await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
assert.containsNone(list, '.o_selected_row');
await testUtils.dom.click(list.$('.o_data_row:nth(2) > td:contains(gnap)'));
assert.containsOnce(list, '.o_selected_row');
assert.ok(list.$('.o_data_row:nth(2)').is('.o_selected_row'),
"third group row should be in edition");
list.destroy();
});
QUnit.test('add and discard a record in a multi-level grouped list view', async function (assert) {
assert.expect(7);
testUtils.mock.patch(basicFields.FieldChar, {
destroy: function () {
assert.step('destroy');
this._super.apply(this, arguments);
},
});
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo" required="1"/></tree>',
groupBy: ['foo', 'bar'],
});
// unfold first subgroup
await testUtils.dom.click(list.$('.o_group_header:first'));
await testUtils.dom.click(list.$('.o_group_header:eq(1)'));
assert.hasClass(list.$('.o_group_header:first'), 'o_group_open');
assert.hasClass(list.$('.o_group_header:eq(1)'), 'o_group_open');
assert.containsOnce(list, '.o_data_row');
// add a record to first subgroup
await testUtils.dom.click(list.$('.o_group_field_row_add a'));
assert.containsN(list, '.o_data_row', 2);
// discard
await testUtils.dom.click(list.$buttons.find('.o_list_button_discard'));
assert.containsOnce(list, '.o_data_row');
assert.verifySteps(['destroy']);
testUtils.mock.unpatch(basicFields.FieldChar);
list.destroy();
});
QUnit.test('inputs are disabled when unselecting rows in grouped editable', async function (assert) {
assert.expect(1);
var $input;
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom"><field name="foo"/></tree>',
mockRPC: function (route, args) {
if (args.method === 'write') {
assert.strictEqual($input.prop('disabled'), true,
"input should be disabled");
}
return this._super.apply(this, arguments);
},
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first'));
await testUtils.dom.click(list.$('td:contains(yop)'));
$input = list.$('tr.o_selected_row input[name="foo"]');
await testUtils.fields.editAndTrigger($input, 'lemon', 'input');
await testUtils.fields.triggerKeydown($input, 'tab');
list.destroy();
});
QUnit.test('pressing ESC in editable grouped list should discard the current line changes', async function (assert) {
assert.expect(5);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
assert.containsN(list, 'tr.o_data_row', 3);
await testUtils.dom.click(list.$('.o_data_cell:first'));
// update name by "foo"
await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row .o_data_cell:first input[name="foo"]'), 'new_value', 'input');
// discard by pressing ESC
await testUtils.fields.triggerKeydown(list.$('input[name="foo"]'), 'escape');
await testUtils.dom.click($('.modal .modal-footer .btn-primary'));
assert.containsOnce(list, 'tbody tr td:contains(yop)');
assert.containsN(list, 'tr.o_data_row', 3);
assert.containsNone(list, 'tr.o_data_row.o_selected_row');
assert.isNotVisible(list.$buttons.find('.o_list_button_save'));
list.destroy();
});
QUnit.test('pressing TAB in editable="bottom" grouped list', async function (assert) {
assert.expect(7);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom"><field name="foo"/></tree>',
groupBy: ['bar'],
});
// open two groups
await testUtils.dom.click(list.$('.o_group_header:first'));
assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
await testUtils.dom.click(list.$('.o_data_cell:first'));
assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
// Press 'Tab' -> should go to next line (still in first group)
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
// Press 'Tab' -> should go to next line (still in first group)
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
// Press 'Tab' -> should go to first line of next group
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
// Press 'Tab' -> should go back to first line of first group
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
list.destroy();
});
QUnit.test('pressing TAB in editable="top" grouped list', async function (assert) {
assert.expect(7);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo"/></tree>',
groupBy: ['bar'],
});
// open two groups
await testUtils.dom.click(list.$('.o_group_header:first'));
assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
await testUtils.dom.click(list.$('.o_data_cell:first'));
assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
// Press 'Tab' -> should go to next line (still in first group)
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
// Press 'Tab' -> should go to next line (still in first group)
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
// Press 'Tab' -> should go to first line of next group
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
// Press 'Tab' -> should go back to first line of first group
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
list.destroy();
});
QUnit.test('pressing TAB in editable grouped list with create=0', async function (assert) {
assert.expect(7);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom" create="0"><field name="foo"/></tree>',
groupBy: ['bar'],
});
// open two groups
await testUtils.dom.click(list.$('.o_group_header:first'));
assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
await testUtils.dom.click(list.$('.o_data_cell:first'));
assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
// Press 'Tab' -> should go to next line (still in first group)
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
// Press 'Tab' -> should go to next line (still in first group)
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
// Press 'Tab' -> should go to first line of next group
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
// Press 'Tab' -> should go back to first line of first group
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.hasClass(list.$('.o_data_row:first'), 'o_selected_row');
list.destroy();
});
QUnit.test('pressing SHIFT-TAB in editable="bottom" grouped list', async function (assert) {
assert.expect(6);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom"><field name="foo" required="1"/></tree>',
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
// navigate inside a group
await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell')); // select second row of first group
assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
// press Shft+tab
list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
await testUtils.nextTick();
assert.hasClass(list.$('tr.o_data_row:first'), 'o_selected_row');
assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
// navigate between groups
await testUtils.dom.click(list.$('.o_data_cell:eq(3)')); // select row of second group
// press Shft+tab
list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
await testUtils.nextTick();
assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
list.destroy();
});
QUnit.test('pressing SHIFT-TAB in editable="top" grouped list', async function (assert) {
assert.expect(6);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo" required="1"/></tree>',
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
// navigate inside a group
await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell')); // select second row of first group
assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
// press Shft+tab
list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
await testUtils.nextTick();
assert.hasClass(list.$('tr.o_data_row:first'), 'o_selected_row');
assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
// navigate between groups
await testUtils.dom.click(list.$('.o_data_cell:eq(3)')); // select row of second group
// press Shft+tab
list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
await testUtils.nextTick();
assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
list.destroy();
});
QUnit.test('pressing SHIFT-TAB in editable grouped list with create="0"', async function (assert) {
assert.expect(6);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top" create="0"><field name="foo" required="1"/></tree>',
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
// navigate inside a group
await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_data_cell')); // select second row of first group
assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
// press Shft+tab
list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
await testUtils.nextTick();
assert.hasClass(list.$('tr.o_data_row:first'), 'o_selected_row');
assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
// navigate between groups
await testUtils.dom.click(list.$('.o_data_cell:eq(3)')); // select row of second group
// press Shft+tab
list.$('tr.o_selected_row input').trigger($.Event('keydown', {which: $.ui.keyCode.TAB, shiftKey: true}));
await testUtils.nextTick();
assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
list.destroy();
});
QUnit.test('editing then pressing TAB in editable grouped list', async function (assert) {
assert.expect(19);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom"><field name="foo"/></tree>',
mockRPC: function (route, args) {
assert.step(args.method || route);
return this._super.apply(this, arguments);
},
groupBy: ['bar'],
});
// open two groups
await testUtils.dom.click(list.$('.o_group_header:first'));
assert.containsN(list, '.o_data_row', 3, 'first group contains 3 rows');
await testUtils.dom.click(list.$('.o_group_header:nth(1)'));
assert.containsN(list, '.o_data_row', 4, 'first group contains 1 row');
// select and edit last row of first group
await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell'));
assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
await testUtils.fields.editInput(list.$('.o_selected_row input[name="foo"]'), 'new value');
// Press 'Tab' -> should create a new record as we edited the previous one
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.containsN(list, '.o_data_row', 5);
assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
// fill foo field for the new record and press 'tab' -> should create another record
await testUtils.fields.editInput(list.$('.o_selected_row input[name="foo"]'), 'new record');
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.containsN(list, '.o_data_row', 6);
assert.hasClass(list.$('.o_data_row:nth(4)'), 'o_selected_row');
// leave this new row empty and press tab -> should discard the new record and move to the
// next group
await testUtils.fields.triggerKeydown(list.$('.o_selected_row input'), 'tab');
assert.containsN(list, '.o_data_row', 5);
assert.hasClass(list.$('.o_data_row:nth(4)'), 'o_selected_row');
assert.verifySteps([
'read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
'write',
'read',
'default_get',
'create',
'read',
'default_get',
]);
list.destroy();
});
QUnit.test('editing then pressing TAB (with a readonly field) in grouped list', async function (assert) {
assert.expect(6);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom"><field name="foo"/><field name="int_field" readonly="1"/></tree>',
mockRPC: function (route, args) {
assert.step(args.method || route);
return this._super.apply(this, arguments);
},
groupBy: ['bar'],
fieldDebounce: 1
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
// click on first td and press TAB
await testUtils.dom.click(list.$('td:contains(yop)'));
await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row input[name="foo"]'), 'new value', 'input');
await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input[name="foo"]'), 'tab');
assert.containsOnce(list, 'tbody tr td:contains(new value)');
assert.verifySteps([
'read_group',
'/web/dataset/search_read',
'write',
'read',
]);
list.destroy();
});
QUnit.test('pressing ENTER in editable="bottom" grouped list view', async function (assert) {
assert.expect(11);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom"><field name="foo"/></tree>',
mockRPC: function (route, args) {
assert.step(args.method || route);
return this._super.apply(this, arguments);
},
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
await testUtils.dom.click(list.$('.o_group_header:nth(1)')); // open second group
assert.containsN(list, 'tr.o_data_row', 4);
await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_data_cell')); // click on second line
assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
// press enter in input should move to next record
await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input'), 'enter');
assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
// press enter on last row should create a new record
await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input'), 'enter');
assert.containsN(list, 'tr.o_data_row', 5);
assert.hasClass(list.$('tr.o_data_row:eq(3)'), 'o_selected_row');
assert.verifySteps([
'/web/dataset/call_kw/foo/read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
'default_get',
]);
list.destroy();
});
QUnit.test('pressing ENTER in editable="top" grouped list view', async function (assert) {
assert.expect(10);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo"/></tree>',
mockRPC: function (route, args) {
assert.step(args.method || route);
return this._super.apply(this, arguments);
},
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
await testUtils.dom.click(list.$('.o_group_header:nth(1)')); // open second group
assert.containsN(list, 'tr.o_data_row', 4);
await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_data_cell')); // click on second line
assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
// press enter in input should move to next record
await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input'), 'enter');
assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
// press enter on last row should move to first record of next group
await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input'), 'enter');
assert.hasClass(list.$('tr.o_data_row:eq(3)'), 'o_selected_row');
assert.doesNotHaveClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
assert.verifySteps([
'read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
]);
list.destroy();
});
QUnit.test('pressing ENTER in editable grouped list view with create=0', async function (assert) {
assert.expect(10);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom" create="0"><field name="foo"/></tree>',
mockRPC: function (route, args) {
assert.step(args.method || route);
return this._super.apply(this, arguments);
},
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open first group
await testUtils.dom.click(list.$('.o_group_header:nth(1)')); // open second group
assert.containsN(list, 'tr.o_data_row', 4);
await testUtils.dom.click(list.$('.o_data_row:nth(1) .o_data_cell')); // click on second line
assert.hasClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
// press enter in input should move to next record
await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input'), 'enter');
assert.hasClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
assert.doesNotHaveClass(list.$('tr.o_data_row:eq(1)'), 'o_selected_row');
// press enter on last row should move to first record of next group
await testUtils.fields.triggerKeydown(list.$('tr.o_selected_row input'), 'enter');
assert.hasClass(list.$('tr.o_data_row:eq(3)'), 'o_selected_row');
assert.doesNotHaveClass(list.$('tr.o_data_row:eq(2)'), 'o_selected_row');
assert.verifySteps([
'read_group',
'/web/dataset/search_read',
'/web/dataset/search_read',
]);
list.destroy();
});
QUnit.test('add a new row in grouped editable="top" list', async function (assert) {
assert.expect(7);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo" required="1"/></tree>',
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open group
await testUtils.dom.click(list.$('.o_group_field_row_add a'));// add a new row
assert.strictEqual(list.$('.o_selected_row .o_input[name=foo]')[0], document.activeElement,
'The first input of the line should have the focus');
assert.containsN(list, 'tbody:nth(1) .o_data_row', 4);
await testUtils.dom.click(list.$buttons.find('.o_list_button_discard')); // discard new row
await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
assert.containsOnce(list, 'tbody:nth(3) .o_data_row');
await testUtils.dom.click(list.$('.o_group_field_row_add a:eq(1)')); // create row in second group
assert.strictEqual(list.$('.o_group_name:eq(1)').text(), 'false (2)',
"group should have correct name and count");
assert.containsN(list, 'tbody:nth(3) .o_data_row', 2);
assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row input[name="foo"]'), 'pla', 'input');
await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
assert.containsN(list, 'tbody:nth(3) .o_data_row', 2);
list.destroy();
});
QUnit.test('add a new row in grouped editable="bottom" list', async function (assert) {
assert.expect(5);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom"><field name="foo" required="1"/></tree>',
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open group
await testUtils.dom.click(list.$('.o_group_field_row_add a'));// add a new row
assert.hasClass(list.$('.o_data_row:nth(3)'), 'o_selected_row');
assert.containsN(list, 'tbody:nth(1) .o_data_row', 4);
await testUtils.dom.click(list.$buttons.find('.o_list_button_discard')); // discard new row
await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
assert.containsOnce(list, 'tbody:nth(3) .o_data_row');
await testUtils.dom.click(list.$('.o_group_field_row_add a:eq(1)')); // create row in second group
assert.hasClass(list.$('.o_data_row:nth(4)'), 'o_selected_row');
await testUtils.fields.editAndTrigger(list.$('tr.o_selected_row input[name="foo"]'), 'pla', 'input');
await testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
assert.containsN(list, 'tbody:nth(3) .o_data_row', 2);
list.destroy();
});
QUnit.test('editable grouped list with create="0"', async function (assert) {
assert.expect(1);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top" create="0"><field name="foo" required="1"/></tree>',
groupBy: ['bar'],
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open group
assert.containsNone(list, '.o_group_field_row_add a',
"Add a line should not be available in readonly");
list.destroy();
});
QUnit.test('add a new row in (selection) grouped editable list', async function (assert) {
assert.expect(6);
this.data.foo.fields.priority = {
string: "Priority",
type: "selection",
selection: [[1, "Low"], [2, "Medium"], [3, "High"]],
default: 1,
};
this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1, priority: 2});
this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 1, priority: 3});
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top">' +
'<field name="foo"/>' +
'<field name="priority"/>' +
'<field name="m2o"/>' +
'</tree>',
groupBy: ['priority'],
mockRPC: function (route, args) {
if (args.method === 'default_get') {
assert.step(args.kwargs.context.default_priority.toString());
}
return this._super.apply(this, arguments);
},
});
await testUtils.dom.click(list.$('.o_group_header:first')); // open group
await testUtils.dom.click(list.$('.o_group_field_row_add a')); // add a new row
await testUtils.dom.click($('body')); // unselect row
assert.verifySteps(['1']);
assert.strictEqual(list.$('.o_data_row .o_data_cell:eq(1)').text(), 'Low',
"should have a column name with a value from the groupby");
await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
await testUtils.dom.click(list.$('.o_group_field_row_add a:eq(1)')); // create row in second group
await testUtils.dom.click($('body')); // unselect row
assert.strictEqual(list.$('.o_data_row:nth(5) .o_data_cell:eq(1)').text(), 'Medium',
"should have a column name with a value from the groupby");
assert.verifySteps(['2']);
list.destroy();
});
QUnit.test('add a new row in (m2o) grouped editable list', async function (assert) {
assert.expect(6);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top">' +
'<field name="foo"/>' +
'<field name="m2o"/>' +
'</tree>',
groupBy: ['m2o'],
mockRPC: function (route, args) {
if (args.method === 'default_get') {
assert.step(args.kwargs.context.default_m2o.toString());
}
return this._super.apply(this, arguments);
},
});
await testUtils.dom.click(list.$('.o_group_header:first'));
await testUtils.dom.click(list.$('.o_group_field_row_add a'));
await testUtils.dom.click($('body')); // unselect row
assert.strictEqual(list.$('tbody:eq(1) .o_data_row:first .o_data_cell:eq(1)').text(), 'Value 1',
"should have a column name with a value from the groupby");
assert.verifySteps(['1']);
await testUtils.dom.click(list.$('.o_group_header:eq(1)')); // open second group
await testUtils.dom.click(list.$('.o_group_field_row_add a:eq(1)')); // create row in second group
await testUtils.dom.click($('body')); // unselect row
assert.strictEqual(list.$('tbody:eq(3) .o_data_row:first .o_data_cell:eq(1)').text(), 'Value 2',
"should have a column name with a value from the groupby");
assert.verifySteps(['2']);
list.destroy();
});
// TODO: write test on:
// - default_get with a field not in view
});
});