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