From e34aeec3bf9d2b6562c5685975038e40d4212e20 Mon Sep 17 00:00:00 2001 From: "Cuong, Nguyen Minh Tran Manh" Date: Fri, 29 Nov 2024 20:11:44 +0700 Subject: [PATCH] [IMP] web_timeline - support multi group by level --- web_timeline/README.rst | 6 +- web_timeline/readme/CONTRIBUTORS.rst | 4 + web_timeline/readme/ROADMAP.rst | 2 - web_timeline/static/description/index.html | 6 +- .../static/src/js/timeline_controller.esm.js | 96 +++--- web_timeline/static/src/js/timeline_model.js | 7 +- .../static/src/js/timeline_renderer.js | 273 +++++++++++++----- web_timeline/static/src/js/timeline_view.js | 14 + 8 files changed, 291 insertions(+), 117 deletions(-) diff --git a/web_timeline/README.rst b/web_timeline/README.rst index a7a3e58cdd29..f8dd01b27e02 100644 --- a/web_timeline/README.rst +++ b/web_timeline/README.rst @@ -181,8 +181,6 @@ Known issues / Roadmap * Implement a more efficient way of refreshing timeline after a record update; * Make ``attrs`` attribute work; -* When grouping by m2m and more than one record is set, the timeline item appears only - on one group. Allow showing in both groups. * When grouping by m2m and dragging for changing the time or the group, the changes on the group will not be set, because it could make disappear the records not related with the changes that we want to make. When the item is showed in all groups change @@ -238,6 +236,10 @@ Contributors * Houzéfa Abbasbhay +* `Komit `_: + + * Cuong Nguyen Mtm + Maintainers ~~~~~~~~~~~ diff --git a/web_timeline/readme/CONTRIBUTORS.rst b/web_timeline/readme/CONTRIBUTORS.rst index 74ab5670e558..84ebdfe39ea0 100644 --- a/web_timeline/readme/CONTRIBUTORS.rst +++ b/web_timeline/readme/CONTRIBUTORS.rst @@ -19,3 +19,7 @@ * `XCG Consulting `_: * Houzéfa Abbasbhay + +* `Komit `_: + + * Cuong Nguyen Mtm diff --git a/web_timeline/readme/ROADMAP.rst b/web_timeline/readme/ROADMAP.rst index 6ddc95d41f79..8bf856356652 100644 --- a/web_timeline/readme/ROADMAP.rst +++ b/web_timeline/readme/ROADMAP.rst @@ -1,7 +1,5 @@ * Implement a more efficient way of refreshing timeline after a record update; * Make ``attrs`` attribute work; -* When grouping by m2m and more than one record is set, the timeline item appears only - on one group. Allow showing in both groups. * When grouping by m2m and dragging for changing the time or the group, the changes on the group will not be set, because it could make disappear the records not related with the changes that we want to make. When the item is showed in all groups change diff --git a/web_timeline/static/description/index.html b/web_timeline/static/description/index.html index 1795f37ac77e..66dacf8da65c 100644 --- a/web_timeline/static/description/index.html +++ b/web_timeline/static/description/index.html @@ -553,8 +553,6 @@

Known issues / Roadmap

  • Implement a more efficient way of refreshing timeline after a record update;
  • Make attrs attribute work;
  • -
  • When grouping by m2m and more than one record is set, the timeline item appears only -on one group. Allow showing in both groups.
  • When grouping by m2m and dragging for changing the time or the group, the changes on the group will not be set, because it could make disappear the records not related with the changes that we want to make. When the item is showed in all groups change @@ -609,6 +607,10 @@

    Contributors

  • Houzéfa Abbasbhay
+
  • Komit: +
  • diff --git a/web_timeline/static/src/js/timeline_controller.esm.js b/web_timeline/static/src/js/timeline_controller.esm.js index 98e05cf56be0..d42d0e4b3db9 100644 --- a/web_timeline/static/src/js/timeline_controller.esm.js +++ b/web_timeline/static/src/js/timeline_controller.esm.js @@ -2,12 +2,12 @@ /* Copyright 2023 Onestein - Anjeel Haria * License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */ import AbstractController from "web.AbstractController"; +import {Component} from "@odoo/owl"; +import Dialog from "web.Dialog"; import {FormViewDialog} from "@web/views/view_dialogs/form_view_dialog"; -import time from "web.time"; import core from "web.core"; -import Dialog from "web.Dialog"; +import time from "web.time"; var _t = core._t; -import {Component} from "@odoo/owl"; export default AbstractController.extend({ custom_events: _.extend({}, AbstractController.prototype.custom_events, { @@ -55,11 +55,29 @@ export default AbstractController.extend({ const group_bys = params.groupBy || this.renderer.last_group_bys || []; this.last_domains = domains; this.last_contexts = contexts; + + const cleanGroupBys = (groupBys) => { + return groupBys.map((group) => { + if (group.includes(":")) { + return group.split(":")[0]; + } + return group; + }); + }; + // Select the group by let n_group_bys = group_bys; - if (!n_group_bys.length && this.renderer.arch.attrs.default_group_by) { - n_group_bys = this.renderer.arch.attrs.default_group_by.split(","); + let arch_attrs_default_group_by = this.renderer.arch.attrs.default_group_by + ? this.renderer.arch.attrs.default_group_by.split(",") + : []; + arch_attrs_default_group_by = cleanGroupBys(arch_attrs_default_group_by); + + if (!n_group_bys.length && arch_attrs_default_group_by.length) { + n_group_bys = arch_attrs_default_group_by; + } else { + n_group_bys = cleanGroupBys(n_group_bys); } + this.renderer.last_group_bys = n_group_bys; this.renderer.last_domains = domains; @@ -73,7 +91,9 @@ export default AbstractController.extend({ kwargs: { fields: fields, domain: domains, - order: [{name: this.renderer.arch.attrs.default_group_by}], + order: arch_attrs_default_group_by.map((group) => { + return {name: group}; + }), }, context: this.getSession().user_context, }).then((data) => @@ -92,11 +112,12 @@ export default AbstractController.extend({ * @returns {jQuery.Deferred} */ _onGroupClick: function (event) { - const groupField = this.renderer.last_group_bys[0]; + const groups = event.data.item.group.split("/"); + const [res_model, res_id] = groups[groups.length - 1].split("-"); return this.do_action({ type: "ir.actions.act_window", - res_model: this.renderer.fields[groupField].relation, - res_id: event.data.item.group, + res_model, + res_id: parseInt(res_id, 10), target: "new", views: [[false, "form"]], }); @@ -119,14 +140,19 @@ export default AbstractController.extend({ * * @private * @param {EventObject} event + * @returns {jQuery.Deferred} */ _onUpdate: function (event) { const item = event.data.item; - const item_id = Number(item.evt.id) || item.evt.id; + const item_id = Number(item.evt.record_id) || item.evt.record_id; return this.openItem(item_id, true); }, - /** Open specified item, either through modal, or by navigating to form view. */ + /** Open specified item, either through modal, or by navigating to form view. + * @param {Number} item_id + * @param {Boolean} is_editable + * @returns {void} + */ openItem: function (item_id, is_editable) { if (this.open_popup_action) { const options = { @@ -166,11 +192,7 @@ export default AbstractController.extend({ const fields = this.renderer.fields; const event_start = item.start; const event_end = item.end; - let group = false; - if (item.group !== -1) { - group = item.group; - } - const data = {}; + let data = {}; // In case of a move event, the date_delay stay the same, // only date_start and stop must be updated data[this.date_start] = time.auto_date_to_str( @@ -194,29 +216,25 @@ export default AbstractController.extend({ ); data[this.date_delay] = diff_seconds / 3600; } - const grouped_field = this.renderer.last_group_bys[0]; - this._rpc({ - model: this.modelName, - method: "fields_get", - args: [grouped_field], - context: this.getSession().user_context, - }).then(async (fields_processed) => { - if ( - this.renderer.last_group_bys && - this.renderer.last_group_bys instanceof Array && - fields_processed[grouped_field].type !== "many2many" - ) { - data[this.renderer.last_group_bys[0]] = group; - } - this.moveQueue.push({ - id: event.data.item.id, - data: data, - event: event, - }); + const group_record_values = this.renderer.groups.reduce((acc, group) => { + if (group.id === item.group) { + return group.group_record_values; + } + return acc; + }, {}); + data = { + ...data, + ...group_record_values, + }; - this.debouncedInternalMove(); + this.moveQueue.push({ + id: event.data.item.id, + data: data, + event: event, }); + + this.debouncedInternalMove(); }, /** @@ -235,7 +253,7 @@ export default AbstractController.extend({ this._rpc({ model: this.model.modelName, method: "write", - args: [[item.event.data.item.id], item.data], + args: [[item.event.data.item.record_id], item.data], context: this.getSession().user_context, }).then(() => { item.event.data.callback(item.event.data.item); @@ -360,12 +378,12 @@ export default AbstractController.extend({ return this._rpc({ model: this.modelName, method: "unlink", - args: [[event.data.item.id]], + args: [[event.data.item.record_id]], context: this.getSession().user_context, }).then(() => { let unlink_index = false; for (var i = 0; i < this.model.data.data.length; i++) { - if (this.model.data.data[i].id === event.data.item.id) { + if (this.model.data.data[i].id === event.data.item.record_id) { unlink_index = i; } } diff --git a/web_timeline/static/src/js/timeline_model.js b/web_timeline/static/src/js/timeline_model.js index f67ce82ae8fc..9a734a1dde64 100644 --- a/web_timeline/static/src/js/timeline_model.js +++ b/web_timeline/static/src/js/timeline_model.js @@ -53,13 +53,18 @@ odoo.define("web_timeline.TimelineModel", function (require) { * @returns {jQuery.Deferred} */ _loadTimeline: function () { + const order = this.default_group_by.split(",").map((group) => { + // Handle the case where the group by is a field with a group operator + // e.g. date:month + return {name: group.includes(":") ? group.split(":")[0] : group}; + }); return this._rpc({ model: this.modelName, method: "search_read", kwargs: { fields: this.fieldNames, domain: this.data.domain, - order: [{name: this.default_group_by}], + order: order, context: this.data.context, }, }).then((events) => { diff --git a/web_timeline/static/src/js/timeline_renderer.js b/web_timeline/static/src/js/timeline_renderer.js index aba23f760392..4feefe67bea9 100644 --- a/web_timeline/static/src/js/timeline_renderer.js +++ b/web_timeline/static/src/js/timeline_renderer.js @@ -331,7 +331,7 @@ odoo.define("web_timeline.TimelineRenderer", function (require) { method: "name_get", args: [ids], context: this.getSession().user_context, - }).then((names) => { + }).then(async (names) => { const nevents = _.map(events, (event) => _.extend( { @@ -340,6 +340,7 @@ odoo.define("web_timeline.TimelineRenderer", function (require) { event ) ); + this.fieldsGet = await this.get_fields_get(group_bys); return this.on_data_loaded_2(nevents, group_bys, adjust_window); }); }, @@ -357,10 +358,35 @@ odoo.define("web_timeline.TimelineRenderer", function (require) { this.grouped_by = group_bys; for (const evt of events) { if (evt[this.date_start]) { - data.push(this.event_data_transform(evt)); + var transformed = this.event_data_transform(evt); + if (Array.isArray(transformed)) { + data.push(...transformed); + } else { + data.push(transformed); + } } } this.split_groups(events, group_bys).then((groups) => { + this.groups = groups; + for (const d of data) { + // If d.group is 'res.partner-35/product.category-4/res.city-false' + // it means there are 3 groups to check + // res.partner-35, res.partner-35/product.category-4, res.partner-35/product.category-4/res.city-false + const groupParts = d.group.split("/"); + let groupPath = ""; + const groupsToCheck = groupParts.map((part, index) => { + groupPath = index === 0 ? part : `${groupPath}/${part}`; + return groupPath; + }); + for (const gtc of groupsToCheck) { + if (gtc.endsWith("-false")) { + const group = groups.find((g) => g.id === gtc); + if (group) { + group.visible = true; + } + } + } + } this.timeline.setGroups(groups); this.timeline.setItems(data); const mode = !this.mode || this.mode === "fit"; @@ -384,64 +410,119 @@ odoo.define("web_timeline.TimelineRenderer", function (require) { return events; } const groups = []; - groups.push({id: -1, content: _t("UNASSIGNED"), order: -1}); - var seq = 1; - for (const evt of events) { - const grouped_field = _.first(group_bys); - const group_name = evt[grouped_field]; - if (group_name) { - if (group_name instanceof Array) { - const group = _.find( - groups, - (existing_group) => existing_group.id === group_name[0] + let seq = 1; + + const groupLevel = group_bys.reduce((acc, g, index) => { + acc[g] = index + 1; + return acc; + }, {}); + + const createGroup = (id, name, parents, lvl) => { + const parentGroups = parents.length ? parents : [null]; + const createdGroups = []; + for (const parent of parentGroups) { + const subGroupId = parent ? `${parent.id}/${id}` : id; + let group = groups.find((g) => g.id === subGroupId); + if (!group) { + const group_record_values = {}; + const group_parts = subGroupId.split("/"); + for (let i = 0; i < group_parts.length; i++) { + const value = group_parts[i].split("-")[1]; + // TODO: skip updating m2m field as it is very complex to handle drag and drop + group_record_values[group_bys[i]] = + value === "false" ? false : Number(value) || value; + } + group = { + id: subGroupId, + content: name || "UNASSIGNED", + group_record_values, + order: name === "UNASSIGNED" ? -1 : seq, + treeLevel: lvl, + visible: name !== "UNASSIGNED", + }; + seq += 1; + groups.push(group); + } + createdGroups.push(group); + if (parent) { + if (!parent.nestedGroups) { + parent.nestedGroups = []; + } + if (!parent.nestedGroups.includes(group.id)) { + parent.nestedGroups.push(group.id); + } + } + } + return createdGroups; + }; + + const processGroup = async ( + grouped_field, + group_name, + group_model, + groupLvl, + parentGroups + ) => { + if (group_name && Array.isArray(group_name)) { + if (this.fieldsGet[grouped_field].type === "many2many") { + const list_values = await this.get_m2m_grouping_datas( + group_model, + group_name ); - if (_.isUndefined(group)) { - // Check if group is m2m in this case add id -> value of all - // found entries. - await this._rpc({ - model: this.modelName, - method: "fields_get", - args: [[grouped_field]], - context: this.getSession().user_context, - }).then(async (fields) => { - if (fields[grouped_field].type === "many2many") { - const list_values = - await this.get_m2m_grouping_datas( - fields[grouped_field].relation, - group_name - ); - for (const vals of list_values) { - let is_inside = false; - for (const gr of groups) { - if (vals.id === gr.id) { - is_inside = true; - break; - } - } - if (!is_inside) { - vals.order = seq; - seq += 1; - groups.push(vals); - } - } - } else { - groups.push({ - id: group_name[0], - content: group_name[1], - order: seq, - }); - seq += 1; - } - }); + const createdM2mGroups = []; + for (const vals of list_values) { + if (group_name.includes(vals.id)) { + const newM2mGroups = createGroup( + `${group_model}-${vals.id}`, + vals.content, + parentGroups, + groupLvl + ); + createdM2mGroups.push(...newM2mGroups); + } } + return createdM2mGroups; } + const groupId = `${group_model}-${group_name[0]}`; + const groupName = group_name[1]; + return createGroup(groupId, groupName, parentGroups, groupLvl); + } else if (group_name && typeof group_name === "string") { + return createGroup( + `${group_model}-${group_name}`, + group_name, + parentGroups, + groupLvl + ); + } + return createGroup( + `${group_model}-false`, + "UNASSIGNED", + parentGroups, + groupLvl + ); + }; + + for (const evt of events) { + let parentGroups = [null]; + for (const grouped_field of group_bys) { + const group_name = evt[grouped_field]; + const group_model = this.fieldsGet[grouped_field].relation; + const groupLvl = groupLevel[grouped_field]; + parentGroups = await processGroup( + grouped_field, + group_name, + group_model, + groupLvl, + parentGroups + ); } } + return groups; }, get_m2m_grouping_datas: async function (model, group_name) { - const groups = []; + const groups = [{id: false, content: "UNASSIGNED"}]; for (const gr of group_name) { await this._rpc({ model: model, @@ -455,6 +536,15 @@ odoo.define("web_timeline.TimelineRenderer", function (require) { return groups; }, + get_fields_get: async function (group_bys) { + return await this._rpc({ + model: this.modelName, + method: "fields_get", + args: [group_bys], + context: this.getSession().user_context, + }); + }, + /** * Get dates from given event * @@ -506,13 +596,43 @@ odoo.define("web_timeline.TimelineRenderer", function (require) { */ event_data_transform: function (evt) { const [date_start, date_stop] = this._get_event_dates(evt); - let group = evt[this.last_group_bys[0]]; - if (group && group instanceof Array && group.length > 0) { - group = _.first(group); - } else { - group = -1; + const evtGroup = []; + let group = "undefined-false"; + for (const grouped_field of this.last_group_bys) { + evtGroup.push({[grouped_field]: evt[grouped_field]}); } + if (evtGroup.length > 1) { + const newGroup = evtGroup.reduce((acc, eG) => { + const entries = Object.entries(eG).flatMap(([f, value]) => { + const coModel = this.fieldsGet[f].relation; + if (value instanceof Array) { + if (this.fieldsGet[f].type === "many2many") { + return value.map((v) => `${coModel}-${v}`); + } + return [`${coModel}-${value[0]}`]; + } else if (typeof value === "string") { + return [`${coModel}-${value}`]; + } + return [`${coModel}-false`]; + }); + if (acc.length === 0) { + return entries.map((e) => [e]); + } + return acc.flatMap((a) => entries.map((e) => [...a, e])); + }, []); + group = newGroup.map((g) => g.join("/")).join(","); + } else if (evtGroup.length === 1) { + const coModel = this.fieldsGet[this.last_group_bys[0]].relation; + const groupValue = evtGroup[0][this.last_group_bys[0]]; + if (groupValue instanceof Array) { + group = `${coModel}-${groupValue[0]}`; + } else if (typeof groupValue === "string") { + group = `${coModel}-${groupValue}`; + } else { + group = `${coModel}-false`; + } + } for (const color of this.colors) { if (py.eval(`'${evt[color.field]}' ${color.opt} '${color.value}'`)) { this.color = color.color; @@ -524,22 +644,33 @@ odoo.define("web_timeline.TimelineRenderer", function (require) { content = this.render_timeline_item(evt); } - const r = { - start: date_start, - content: content, - id: evt.id, - order: evt.order, - group: group, - evt: evt, - style: `background-color: ${this.color};`, - }; - // Only specify range end when there actually is one. - // ➔ Instantaneous events / those with inverted dates are displayed as points. - if (date_stop && moment(date_start).isBefore(date_stop)) { - r.end = date_stop; + const groups = group.split(","); + const r_list = []; + for (const g of groups) { + const r = { + start: date_start, + content: content, + // Append group to the id to avoid duplicate id, one item can be + // appear/duplicated in multiple groups in case group by m2m field. + id: evt.id + "_" + g, + record_id: evt.id, + order: evt.order, + group: g, + evt: evt, + style: `background-color: ${this.color};`, + }; + // Only specify range end when there actually is one. + // ➔ Instantaneous events / those with inverted dates are displayed as points. + if (date_stop && moment(date_start).isBefore(date_stop)) { + r.end = date_stop; + } + this.color = null; + if (groups.length === 1) { + return r; + } + r_list.push(r); } - this.color = null; - return r; + return r_list; }, /** diff --git a/web_timeline/static/src/js/timeline_view.js b/web_timeline/static/src/js/timeline_view.js index 3971db4f750a..13716cc66926 100644 --- a/web_timeline/static/src/js/timeline_view.js +++ b/web_timeline/static/src/js/timeline_view.js @@ -70,6 +70,20 @@ odoo.define("web_timeline.TimelineView", function (require) { } } + const fieldNamesCopy = [...fieldNames]; + for (const field of fieldNamesCopy) { + if (field.includes(",")) { + // Multiple group by fields + fieldNames.pop(field); + fieldNames.push(...field.split(",")); + } + if (field.includes(":")) { + // Group by date field may have a group operator, e.g. date:month + fieldNames.pop(field); + fieldNames.push(field.split(":")[0]); + } + } + const archFieldNames = _.map( _.filter(this.arch.children, (item) => item.tag === "field"), (item) => item.attrs.name