[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:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user