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