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 c96b677e2ea..c4d1ca8959f 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -342,6 +342,15 @@ var ListController = BasicController.extend({
var record = this.model.get(this.handle);
return _.extend(env, {domain: record.getDomain()});
},
+ /**
+ * @private
+ * @returns {boolean}
+ */
+ _inMultipleRecordEdition: function (recordId) {
+ var record = this.model.get(recordId, { raw: true });
+ var recordIds = _.union([recordId], this.selectedRecords);
+ return recordIds.length > 1 && record.res_id;
+ },
/**
* Only display the pager when there are data to display.
*
@@ -352,6 +361,57 @@ var ListController = BasicController.extend({
var state = this.model.get(this.handle, {raw: true});
return !!state.count;
},
+ /**
+ * @private
+ * @param {string} recordId
+ * @param {Object} node
+ * @param {Object} changes
+ */
+ _saveMultipleRecords: function (recordId, node, changes) {
+ var self = this;
+ var value = Object.values(changes)[0];
+ var recordIds = _.union([recordId], this.selectedRecords);
+ var validRecordIds = recordIds.reduce(function (result, recordId) {
+ var record = self.model.get(recordId);
+ var modifiers = self.renderer._registerModifiers(node, record);
+ if (!modifiers.readonly && (!modifiers.required || value)) {
+ result.push(recordId);
+ }
+ return result;
+ }, []);
+ var message = _.str.sprintf(
+ _t('Do you want to set the value on the %d valid selected records?'),
+ validRecordIds.length);
+ if (recordIds.length !== validRecordIds.length) {
+ var nbInvalid = recordIds.length - validRecordIds.length;
+ message += ' ' + _.str.sprintf(_t('(%d invalid)'), nbInvalid);
+ }
+ Dialog.confirm(this, message, {
+ confirm_callback: function () {
+ self.model.saveRecords(recordId, validRecordIds)
+ .then(function () {
+ self._updateButtons('readonly');
+ var state = self.model.get(self.handle);
+ self.renderer.updateState(state, {});
+ });
+ },
+ });
+ },
+ /**
+ * Overridden to deal with edition of multiple line.
+ *
+ * @override
+ * @param {string} recordId
+ */
+ _saveRecord: function (recordId) {
+ var record = this.model.get(recordId, { raw: true });
+ if (record.isDirty() && this._inMultipleRecordEdition(recordId)) {
+ // do not save the record (see _saveMultipleRecords)
+ return Promise.resolve();
+
+ }
+ return this._super.apply(this, arguments);
+ },
/**
* Allows to change the mode of a single row.
*
@@ -545,6 +605,30 @@ var ListController = BasicController.extend({
flags: {mode: 'edit'},
});
},
+ /**
+ * Overridden to deal with the edition of multiple records.
+ *
+ * Note that we don't manage saving multiple records on saveLine
+ * because we don't want the onchanges to be applied.
+ *
+ * @private
+ * @override
+ */
+ _onFieldChanged: function (ev) {
+ ev.stopPropagation();
+ var self = this;
+
+ if (this._inMultipleRecordEdition(ev.data.dataPointID)) {
+ // deal with edition of multiple lines
+ var _onSuccess = ev.data.onSuccess;
+ ev.data.onSuccess = function () {
+ Promise.resolve(_onSuccess()).then(function () {
+ self._saveMultipleRecords(ev.data.dataPointID, ev.target.__node, ev.data.changes);
+ });
+ };
+ }
+ this._super.apply(this, arguments);
+ },
/**
* Force a resequence of the records curently on this page.
*
@@ -579,8 +663,7 @@ var ListController = BasicController.extend({
* @param {OdooEvent} ev
*/
_onSaveLine: function (ev) {
- var recordID = ev.data.recordID;
- this.saveRecord(recordID)
+ this.saveRecord(ev.data.recordID)
.then(ev.data.onSuccess)
.guardedCatch(ev.data.onFailure);
},
diff --git a/addons/web/static/src/js/views/list/list_model.js b/addons/web/static/src/js/views/list/list_model.js
index 0be8f54e4f1..73c2d86864d 100644
--- a/addons/web/static/src/js/views/list/list_model.js
+++ b/addons/web/static/src/js/views/list/list_model.js
@@ -33,6 +33,52 @@ odoo.define('web.ListModel', function (require) {
}
return result;
},
+ /**
+ * For a list of records, performs a write with all changes and fetches
+ * all data.
+ *
+ * @param {string} referenceRecordId the record datapoint used to
+ * generate the changes to apply to recordIds
+ * @param {string[]} recordIds a list of record datapoint ids
+ */
+ saveRecords: function (referenceRecordId, recordIds) {
+ var self = this;
+ var referenceRecord = this.localData[referenceRecordId];
+ var list = this.localData[referenceRecord.parentID];
+ var changes = this._generateChanges(referenceRecord, {});
+ var records = recordIds.map(function (recordId) {
+ return self.localData[recordId];
+ });
+ var model = records[0].model;
+ var recordResIds = _.pluck(records, 'res_id');
+ var fieldNames = records[0].getFieldNames();
+
+ return this._rpc({
+ model: model,
+ method: 'write',
+ args: [recordResIds, changes],
+ context: records[0].getContext(),
+ }).then(function () {
+ return self._rpc({
+ model: model,
+ method: 'read',
+ args: [recordResIds, fieldNames],
+ });
+ }).then(function (results) {
+ results.forEach(function (data) {
+ var record = _.findWhere(records, {res_id: data.id});
+ record.data = _.extend({}, record.data, data);
+ record._changes = {};
+ record._isDirty = false;
+ self._parseServerData(fieldNames, record, record.data);
+ });
+ }).then(function () {
+ return Promise.all([
+ self._fetchX2ManysBatched(list),
+ self._fetchReferencesBatched(list)
+ ]);
+ });
+ },
//--------------------------------------------------------------------------
// Private
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index cb6a1ef805d..83b5ebd25c5 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -3833,6 +3833,112 @@ QUnit.module('Views', {
list.destroy();
});
+ QUnit.test('editable list view: multi edition', 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);
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1, 2], { int_field: 666 }],
+ "should write on multi records");
+ } else if (args.method === 'read') {
+ if (args.args[0].length !== 1) {
+ assert.deepEqual(args.args, [[1, 2], ['foo', 'int_field']],
+ "should batch the read");
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.verifySteps(['/web/dataset/search_read']);
+
+ // select two records
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_list_record_selector input'));
+ await testUtils.dom.click(list.$('.o_data_row:eq(1) .o_list_record_selector input'));
+
+ // edit a line witout modifying a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ assert.hasClass(list.$('.o_data_row:eq(0)'), 'o_selected_row',
+ "the first row should be selected");
+ await testUtils.dom.click('body');
+ assert.containsNone(list, '.o_selected_row', "no row should be selected");
+
+ // create a record and edit its value
+ await testUtils.dom.click($('.o_list_button_add'));
+ assert.verifySteps(['default_get']);
+
+ await testUtils.fields.editInput(list.$('.o_selected_row .o_field_widget[name=int_field]'), 123);
+ assert.containsNone($, '.modal', "the multi edition should not be triggered during creation");
+
+ await testUtils.dom.click($('.o_list_button_save'));
+ assert.verifySteps(['create', 'read']);
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+ assert.containsOnce($, '.modal', "there should be an opened modal");
+ assert.ok($('.modal').text().includes('2 valid'), "the number of records should be correctly displayed");
+
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.verifySteps(['write', 'read']);
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "yop666",
+ "the first row should be updated");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "blip666",
+ "the second row should be updated");
+
+ list.destroy();
+ });
+
+ QUnit.test('editable list view: multi edition with readonly modifiers', async function (assert) {
+ assert.expect(5);
+
+ var list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '' +
+ '' +
+ '' +
+ '" , 2)]}\'/>' +
+ '',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args, [[1, 2], { int_field: 666 }],
+ "should only write on the valid records");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // select all records
+ await testUtils.dom.click(list.$('th.o_list_record_selector input'));
+
+ // edit a field
+ await testUtils.dom.click(list.$('.o_data_row:eq(0) .o_data_cell:eq(1)'));
+ await testUtils.fields.editInput(list.$('.o_field_widget[name=int_field]'), 666);
+ assert.ok($('.modal').text().includes('2 valid'),
+ "the number of records should be correctly displayed (only 2 not readonly)");
+ assert.ok($('.modal').text().includes('2 invalid'),
+ "should display the number of invalid records");
+
+ await testUtils.dom.click($('.modal .btn-primary'));
+ assert.strictEqual(list.$('.o_data_row:eq(0) .o_data_cell').text(), "1yop666",
+ "the first row should be updated");
+ assert.strictEqual(list.$('.o_data_row:eq(1) .o_data_cell').text(), "2blip666",
+ "the second row should be updated");
+
+ list.destroy();
+ });
+
QUnit.test('list grouped by date:month', async function (assert) {
assert.expect(1);