[IMP] web: support many2many grouping

PURPOSE

Allow to group records by many2many fields.

Use case: turn the user_id m2o field on project.task into a user_ids m2m field
in order to assign multiple collaborators on a task

SPECIFICATION

Add many2many fields to the 'list of groupable fields'.
When grouping a set of records by a many2many, records linked to multiple
records of the comodel will essentially be in multiple 'groups' (even though
those are not really 'groups' anymore).
There will also be a "null" group, for records that have an empty value of the
many2many, like "unassigned" records.

Example: grouping a project.task linked to 10 res.users by the user_ids
many2many field, the project.task will be 'rendered/considered/displayed' 10
times, once per res.users.
Kind of like if we grouped the m2m relation table instead.

When grouping by a m2m field:
- listview
    editing an instance of a record also updates each other instance(if any)
        - the same goes when scheduling an activity, etc.
    disable deletion
        - the risk here is that the user might expected that this will 'unlike'
        the record from a specific record of the many2many field
    disable archive/unarchive
- kanban
    disable drag&drop between groups
    disable deletion
    disable archive/unarchive
- searchview
    display many2many fields custom groupby dropdown

With this commit, we also support groupby many2many field in mockServer so that
test cases are written based on that.

task-2508608

closes odoo/odoo#74985

Related: odoo/enterprise#20192
Signed-off-by: Lucas Perais (lpe) <lpe@odoo.com>
This commit is contained in:
Bruno Boi
2021-08-26 16:24:59 +00:00
parent e2f3ac24d0
commit cbc9f3c649
9 changed files with 132 additions and 64 deletions
@@ -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);
}
@@ -151,6 +151,7 @@ odoo.define('web.searchUtils', function (require) {
'datetime',
'integer',
'many2one',
'many2many',
'selection',
];
const DEFAULT_INTERVAL = 'month';
@@ -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
@@ -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') {
@@ -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
@@ -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.
@@ -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) {
+1 -1
View File
@@ -23,7 +23,7 @@
<a t-if="widget.editable and widget.id" role="menuitem" class="dropdown-item o_column_edit" href="#">Edit Stage</a>
<a t-if="widget.deletable and widget.id" role="menuitem" class="dropdown-item o_column_delete" href="#">Delete</a>
</t>
<t t-if="widget.has_active_field and widget.archivable">
<t t-if="widget.has_active_field and widget.archivable and !widget.grouped_by_m2m">
<a role="menuitem" href="#" class="dropdown-item o_column_archive_records">Archive All</a>
<a role="menuitem" href="#" class="dropdown-item o_column_unarchive_records">Unarchive All</a>
</t>
@@ -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();