diff --git a/addons/web/static/src/legacy/js/control_panel/groupby_menu.js b/addons/web/static/src/legacy/js/control_panel/groupby_menu.js
index 73db1fe9201..13303bf1bbf 100644
--- a/addons/web/static/src/legacy/js/control_panel/groupby_menu.js
+++ b/addons/web/static/src/legacy/js/control_panel/groupby_menu.js
@@ -57,12 +57,14 @@ odoo.define('web.GroupByMenu', function (require) {
//---------------------------------------------------------------------
/**
+ * @see {@link odoo/fields.py} Field._description_sortable
+ * @see {@link odoo/fields.py} Many2Many.groupable
* @private
* @param {Object} field
* @returns {boolean}
*/
_validateField(field) {
- return field.sortable &&
+ return (field.sortable || (field.type === "many2many" && field.store)) &&
field.name !== "id" &&
GROUPABLE_TYPES.includes(field.type);
}
diff --git a/addons/web/static/src/legacy/js/control_panel/search_utils.js b/addons/web/static/src/legacy/js/control_panel/search_utils.js
index 4618ea1851d..ab82f82e429 100644
--- a/addons/web/static/src/legacy/js/control_panel/search_utils.js
+++ b/addons/web/static/src/legacy/js/control_panel/search_utils.js
@@ -151,6 +151,7 @@ odoo.define('web.searchUtils', function (require) {
'datetime',
'integer',
'many2one',
+ 'many2many',
'selection',
];
const DEFAULT_INTERVAL = 'month';
diff --git a/addons/web/static/src/legacy/js/views/basic/basic_model.js b/addons/web/static/src/legacy/js/views/basic/basic_model.js
index ef9270e4f57..baec22175c3 100644
--- a/addons/web/static/src/legacy/js/views/basic/basic_model.js
+++ b/addons/web/static/src/legacy/js/views/basic/basic_model.js
@@ -647,6 +647,7 @@ var BasicModel = AbstractModel.extend({
groupsOffset: element.groupsOffset,
id: element.id,
isDirty: element.isDirty,
+ isM2MGrouped: element.isM2MGrouped,
isNew: element.isNew,
isOpen: element.isOpen,
isSample: this.isSampleModel,
@@ -1205,6 +1206,9 @@ var BasicModel = AbstractModel.extend({
}
});
prom.then(function () {
+ self._updateDuplicateRecords(record.id, (id) => {
+ Object.assign(self.localData[id].data, record.data);
+ });
record._isDirty = false;
});
return prom;
@@ -4098,6 +4102,13 @@ var BasicModel = AbstractModel.extend({
var res_ids = params.res_ids || [];
var data = params.data || (type === 'record' ? {} : []);
var context = params.context;
+ var fields = _.extend({
+ display_name: {type: 'char'},
+ id: {type: 'integer'},
+ }, params.fields);
+
+ const groupedBy = params.groupedBy || [];
+ let isM2MGrouped = false;
if (type === 'record') {
res_id = params.res_id || (params.data && params.data.id);
if (res_id) {
@@ -4112,13 +4123,12 @@ var BasicModel = AbstractModel.extend({
var isValueArray = params.value instanceof Array;
res_id = isValueArray ? params.value[0] : undefined;
value = isValueArray ? params.value[1] : params.value;
+ isM2MGrouped = groupedBy.some((group) => {
+ const [fieldName] = group.split(':');
+ return fields[fieldName].type === "many2many";
+ });
}
- var fields = _.extend({
- display_name: {type: 'char'},
- id: {type: 'integer'},
- }, params.fields);
-
var dataPoint = {
_cache: type === 'list' ? {} : undefined,
_changes: null,
@@ -4131,11 +4141,12 @@ var BasicModel = AbstractModel.extend({
domain: params.domain || [],
fields: fields,
fieldsInfo: params.fieldsInfo,
- groupedBy: params.groupedBy || [],
+ groupedBy,
groupsCount: 0,
groupsLimit: type === 'list' && params.groupsLimit || null,
groupsOffset: 0,
id: `${params.modelName}_${++this.__id}`,
+ isM2MGrouped,
isOpen: params.isOpen,
limit: type === 'record' ? 1 : (params.limit || Number.MAX_SAFE_INTEGER),
loadMoreOffset: 0,
@@ -4959,6 +4970,10 @@ var BasicModel = AbstractModel.extend({
}
if (options.groupBy !== undefined) {
element.groupedBy = options.groupBy;
+ element.isM2MGrouped = element.groupedBy.some((group) => {
+ const [fieldName] = group.split(':');
+ return element.fields[fieldName].type === "many2many";
+ });
}
if (options.limit !== undefined) {
element.limit = options.limit;
@@ -5274,6 +5289,40 @@ var BasicModel = AbstractModel.extend({
});
});
},
+ /**
+ * Used to apply a updateFn to other records with same res_id, that are
+ * under the same topmost list groupedby many2many.
+ *
+ * When a list datapoint is grouped by many2many, same res_id record could
+ * be in multiple groups, so use this method to apply the same callback to
+ * each one of them.
+ *
+ * @protected
+ * @param {string} recordID local resource
+ * @param {(recordID: string)=>void} updateFn called for each record datapoint
+ * representing the same res_id record
+ */
+ _updateDuplicateRecords(recordID, updateFn) {
+ const { model, res_id } = this.localData[recordID];
+ // Get the topmost groupedby m2m list
+ const getTopmostID = () => {
+ let element = this.localData[recordID];
+ while (element.parentID && this.localData[element.parentID].type === "list") {
+ element = this.localData[element.parentID];
+ }
+ return element.type === "list" && element.isM2MGrouped ? element.id : false;
+ };
+ const topmostID = getTopmostID();
+ if (!topmostID) {
+ return;
+ }
+ const topmostList = this.get(topmostID);
+ utils.traverse_records(topmostList, (r) => {
+ if (r.res_id === res_id && r.id !== recordID) {
+ updateFn(r.id);
+ }
+ });
+ },
/**
* Helper method. Recursively traverses the data, starting from the element
* record (or list), then following all relations. This is useful when one
diff --git a/addons/web/static/src/legacy/js/views/kanban/kanban_column.js b/addons/web/static/src/legacy/js/views/kanban/kanban_column.js
index 2a48388cf00..907f23d5918 100644
--- a/addons/web/static/src/legacy/js/views/kanban/kanban_column.js
+++ b/addons/web/static/src/legacy/js/views/kanban/kanban_column.js
@@ -54,6 +54,7 @@ var KanbanColumn = Widget.extend({
this.quickCreateView = options.quickCreateView;
this.groupedBy = options.groupedBy;
this.grouped_by_m2o = options.grouped_by_m2o;
+ this.grouped_by_m2m = options.grouped_by_m2m;
this.editable = options.editable;
this.deletable = options.deletable;
this.archivable = options.archivable;
@@ -77,8 +78,8 @@ var KanbanColumn = Widget.extend({
this.record_options = _.clone(recordOptions);
- if (options.grouped_by_m2o || options.grouped_by_date ) {
- // For many2one and datetime, a false value means that the field is not set.
+ if (options.grouped_by_m2o || options.grouped_by_date || options.grouped_by_m2m) {
+ // For many2x and datetime, a false value means that the field is not set.
this.title = value ? value : _t('Undefined');
} else {
// False and 0 might be valid values for these fields.
@@ -250,6 +251,9 @@ var KanbanColumn = Widget.extend({
* @return {Promise}
*/
_addRecord: function (recordState, options) {
+ if (this.grouped_by_m2m) {
+ this.record_options.deletable = false;
+ }
var record = new this.KanbanRecord(this, recordState, this.record_options);
this.records.push(record);
if (options && options.position === 'before') {
diff --git a/addons/web/static/src/legacy/js/views/kanban/kanban_renderer.js b/addons/web/static/src/legacy/js/views/kanban/kanban_renderer.js
index 3c05f8a36f6..5703e804ada 100644
--- a/addons/web/static/src/legacy/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/legacy/js/views/kanban/kanban_renderer.js
@@ -491,51 +491,42 @@ var KanbanRenderer = BasicRenderer.extend({
*
* @override
*/
- _setState: function () {
+ _setState() {
this._super(...arguments);
+ const groupedBy = this.state.groupedBy[0];
+ const groupByFieldName = viewUtils.getGroupByField(groupedBy);
+ const field = this.state.fields[groupByFieldName] || {};
+ const fieldInfo = this.state.fieldsInfo.kanban[groupByFieldName] || {};
- var groupByField = this.state.groupedBy[0];
- var cleanGroupByField = viewUtils.getGroupByField(groupByField);
- var groupByFieldAttrs = this.state.fields[cleanGroupByField];
- var groupByFieldInfo = this.state.fieldsInfo.kanban[cleanGroupByField];
- // Deactivate the drag'n'drop:
+ const group_by_tooltip = fieldInfo.options && fieldInfo.options.group_by_tooltip;
+ const grouped_by_date = ["date", "datetime"].includes(field.type);
+ const grouped_by_m2m = field.type === "many2many";
+ const grouped_by_m2o = field.type === "many2one";
+ const readonly = !!field.readonly || !!fieldInfo.readonly;
+ const relation = (grouped_by_m2o || grouped_by_m2m) && field.relation;
+ const quick_create = this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(this.state);
+
+ // Deactivate the drag'n'drop either:
// - if the groupedBy field is readonly (on the field attrs or in the view)
+ // - if the groupedBy field is of type many2many
// - for date and datetime if :
// - allowGroupRangeValue is not true
- var readonly = false;
- var draggable = true;
- var grouped_by_date = false;
- if (groupByFieldAttrs) {
- if (groupByFieldAttrs.type === "date" || groupByFieldAttrs.type === "datetime") {
- draggable = false;
- grouped_by_date = true;
- }
- if (groupByFieldAttrs.readonly !== undefined) {
- readonly = groupByFieldAttrs.readonly;
- }
- }
- if (groupByFieldInfo) {
- if (grouped_by_date) {
- draggable = groupByFieldInfo.allowGroupRangeValue;
- }
- if (!readonly && groupByFieldInfo.readonly !== undefined) {
- readonly = groupByFieldInfo.readonly;
- }
- }
- draggable = !readonly && draggable;
- this.groupedByM2O = groupByFieldAttrs && (groupByFieldAttrs.type === 'many2one');
- var relation = this.groupedByM2O && groupByFieldAttrs.relation;
- var groupByTooltip = groupByFieldInfo && groupByFieldInfo.options.group_by_tooltip;
- this.columnOptions = _.extend(this.columnOptions, {
- draggable: draggable,
- group_by_tooltip: groupByTooltip,
- groupedBy: groupByField,
- grouped_by_m2o: this.groupedByM2O,
- grouped_by_date: grouped_by_date,
- relation: relation,
- quick_create: this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(this.state),
+ const draggable = !readonly && !grouped_by_m2m &&
+ (!grouped_by_date || fieldInfo.allowGroupRangeValue);
+
+ Object.assign(this.columnOptions, {
+ draggable,
+ groupedBy,
+ grouped_by_date,
+ grouped_by_m2o,
+ grouped_by_m2m,
+ group_by_tooltip,
+ quick_create,
+ relation,
});
- this.createColumnEnabled = this.groupedByM2O && this.columnOptions.group_creatable;
+
+ this.createColumnEnabled = grouped_by_m2o && this.columnOptions.group_creatable;
+ this.groupedByM2O = grouped_by_m2o;
},
/**
* Moves the focus on the first card of the next column in a given direction
diff --git a/addons/web/static/src/legacy/js/views/list/list_controller.js b/addons/web/static/src/legacy/js/views/list/list_controller.js
index 3815c89eee4..14541756f68 100644
--- a/addons/web/static/src/legacy/js/views/list/list_controller.js
+++ b/addons/web/static/src/legacy/js/views/list/list_controller.js
@@ -312,12 +312,14 @@ var ListController = BasicController.extend({
* @override
* @param {string} id a basicmodel valid resource handle. It is supposed to
* be a record from the list view.
+ * @param {Object} [options] render options
* @returns {Promise}
*/
- _confirmSave: function (id) {
- var state = this.model.get(this.handle);
- return this._updateRendererState(state, { noRender: true })
- .then(this._setMode.bind(this, 'readonly', id));
+ _confirmSave(id, options = {}) {
+ const state = this.model.get(this.handle);
+ return this._updateRendererState(state, { noRender: !state.isM2MGrouped, ...options }).then(
+ this._setMode.bind(this, "readonly", id)
+ );
},
/**
* Deletes records matching the current domain. We limit the number of
@@ -423,6 +425,7 @@ var ListController = BasicController.extend({
* @private
*/
_getActionMenuItems: function (state) {
+ const { isM2MGrouped } = state;
if (!this.hasActionMenus || !this.selectedRecords.length) {
return null;
}
@@ -434,7 +437,7 @@ var ListController = BasicController.extend({
callback: () => this._onExportData()
});
}
- if (this.archiveEnabled) {
+ if (this.archiveEnabled && !isM2MGrouped) {
otherActionItems.push({
description: _t("Archive"),
callback: () => {
@@ -450,7 +453,7 @@ var ListController = BasicController.extend({
callback: () => this._toggleArchiveState(false)
});
}
- if (this.activeActions.delete) {
+ if (this.activeActions.delete && !isM2MGrouped) {
otherActionItems.push({
description: _t("Delete"),
callback: () => this._onDeleteSelectedRecords()
@@ -492,13 +495,10 @@ var ListController = BasicController.extend({
this.model.saveRecords(this.handle, recordId, validRecordIds, Object.keys(changes))
.then(async () => {
this.updateButtons('readonly');
- const state = this.model.get(this.handle);
- // We need to check the current multi-editable state here
- // in case the selection is changed. If there are changes
- // and the list was multi-editable, we do not want to select
- // the next row.
+ // If there are changes and the list was multi-editable,
+ // we do not want to select the next row.
this.selectedRecords = [];
- await this._updateRendererState(state, {
+ await this._confirmSave(this.handle, {
keepWidths: true,
selectedRecords: [],
});
@@ -910,6 +910,19 @@ var ListController = BasicController.extend({
this._enableButtons();
}
},
+ /**
+ * Overridden to always reload the main record when grouped by M2M.
+ *
+ * @override
+ */
+ _onReload(ev) {
+ const { isM2MGrouped } = this.model.get(this.handle);
+ if (isM2MGrouped) {
+ // Ask for the main record to be reloaded.
+ ev.data.db_id = this.handle;
+ }
+ this._super(...arguments);
+ },
/**
* Called when the renderer displays an editable row and the user tries to
* leave it -> Saves the record associated to that line.
diff --git a/addons/web/static/src/legacy/js/views/list/list_model.js b/addons/web/static/src/legacy/js/views/list/list_model.js
index 7ea05d462c7..def1079d6fc 100644
--- a/addons/web/static/src/legacy/js/views/list/list_model.js
+++ b/addons/web/static/src/legacy/js/views/list/list_model.js
@@ -73,12 +73,20 @@
context: context,
});
}).then(function (results) {
- results.forEach(function (data) {
- var record = _.findWhere(records, {res_id: data.id});
+ const updateLocalRecord = (id, data) => {
+ const record = self.localData[id];
record.data = _.extend({}, record.data, data);
record._changes = {};
record._isDirty = false;
self._parseServerData(fieldNames, record, record.data);
+ };
+
+ results.forEach(function (data) {
+ const record = _.findWhere(records, { res_id: data.id });
+ updateLocalRecord(record.id, data);
+
+ // Also update same resId records
+ self._updateDuplicateRecords(record.id, (id) => updateLocalRecord(id, data));
});
}).then(function () {
if (!list.groupedBy.length) {
diff --git a/addons/web/static/src/legacy/xml/kanban.xml b/addons/web/static/src/legacy/xml/kanban.xml
index b011430c48c..27419b5a6a8 100644
--- a/addons/web/static/src/legacy/xml/kanban.xml
+++ b/addons/web/static/src/legacy/xml/kanban.xml
@@ -23,7 +23,7 @@
Edit Stage
Delete
-
+
Archive All
Unarchive All
diff --git a/addons/web/static/tests/legacy/views/kanban_tests.js b/addons/web/static/tests/legacy/views/kanban_tests.js
index 8eac85a0dd5..108a9c1680b 100644
--- a/addons/web/static/tests/legacy/views/kanban_tests.js
+++ b/addons/web/static/tests/legacy/views/kanban_tests.js
@@ -7119,7 +7119,7 @@ QUnit.module('Views', {
"the kanban view should not be ungrouped");
kanban.update({domain: []}); // 1st update on kanban view
- kanban.update({groupBy: false}); // 2n update on kanban view
+ kanban.update({groupBy: []}); // 2n update on kanban view
prom.resolve(); // simulate slow 1st update of kanban view
await nextTick();