diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index ef916d31ff7..7be77cedca0 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -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);
},
/**
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index 79fac0f2247..150b97ff0d8 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -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.
diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js
index 6f5fa263ba8..f741e11b789 100644
--- a/addons/web/static/src/js/views/basic/basic_renderer.js
+++ b/addons/web/static/src/js/views/basic/basic_renderer.js
@@ -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
diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js
index c0bb18341f2..96125727feb 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -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
diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js
index 055cd8177e3..217f02c8fd1 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -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);
},
});
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
index cb652cb968b..9e31a188922 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -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 = $('')
+ .text(_t("Add a line"))
+ .data('groupID', group.id);
+ var $td = $('
')
+ .attr('colspan', this._getNumberOfCols())
+ .addClass('o_group_field_row_add')
+ .append($a);
+ var $tr = $(' | ', {class: 'o_add_record_row'})
+ .data('groupID', group.id)
+ .append($td);
+ $groupBody.append($tr.prepend($('| ').html(' ')));
+ }
+ 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);
}
},
diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js
index 760ab9ed7a8..e6462d99609 100644
--- a/addons/web/static/src/js/views/list/list_renderer.js
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -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 |
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 [$('
').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($('').append($records));
- }
+ result = result.concat(self._renderGroup(group, groupLevel));
$tbody = null;
}
});
@@ -594,7 +605,7 @@ var ListRenderer = BasicRenderer.extend({
});
var $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));
diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js
index a28eb4da03a..60f0bc32cf8 100644
--- a/addons/web/static/src/js/views/list/list_view.js
+++ b/addons/web/static/src/js/views/list/list_view.js
@@ -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';
diff --git a/addons/web/static/src/js/views/view_utils.js b/addons/web/static/src/js/views/view_utils.js
index ea2270ce466..82cefe5bce3 100644
--- a/addons/web/static/src/js/views/view_utils.js
+++ b/addons/web/static/src/js/views/view_utils.js
@@ -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
}
},
/**
diff --git a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js
index be2cad2f1f9..fa83e91bfbe 100644
--- a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js
+++ b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js
@@ -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();
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index ac34dd95028..48e4c975127 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -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: '',
- 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: '',
- 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 = '';
@@ -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: '',
+ 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: '',
+ });
+
+ // 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '',
+ 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: '' +
+ '' +
+ '' +
+ '' +
+ '',
+ 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: '' +
+ '' +
+ '' +
+ '',
+ 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
});
});