From 0a66d460ca6a934a78c21d8a40cedebed259f659 Mon Sep 17 00:00:00 2001
From: "Cuong, Nguyen Minh Tran Manh"
Date: Fri, 3 Oct 2025 20:30:23 +0700
Subject: [PATCH 1/3] [IMP] web_timeline - support multi group by level
---
web_timeline/README.rst | 76 +++----
web_timeline/readme/USAGE.md | 2 +-
web_timeline/static/description/index.html | 6 +-
.../views/timeline/timeline_controller.esm.js | 53 +++--
.../src/views/timeline/timeline_model.esm.js | 80 ++++++--
.../views/timeline/timeline_renderer.esm.js | 192 ++++++++++++++----
6 files changed, 291 insertions(+), 118 deletions(-)
diff --git a/web_timeline/README.rst b/web_timeline/README.rst
index 4f53aaec1bac..58e95f3abd9f 100644
--- a/web_timeline/README.rst
+++ b/web_timeline/README.rst
@@ -133,12 +133,12 @@ render the timeline items. You have to name the template
'timeline-item'. These are the variables available in template
rendering:
-- ``record``: to access the fields values selected in the timeline
- definition.
-- ``formatters``: used to format values (see available functions in
- ``@web/views/fields/formatters``).
-- ``parsers``: used to parse values (see available functions in
- ``@web/views/fields/parsers``).
+- ``record``: to access the fields values selected in the timeline
+ definition.
+- ``formatters``: used to format values (see available functions in
+ ``@web/views/fields/formatters``).
+- ``parsers``: used to parse values (see available functions in
+ ``@web/views/fields/parsers``).
You also need to declare the view in an action window of the involved
model.
@@ -233,7 +233,7 @@ Records are grouped in different blocks depending on the group by
criteria selected (if none is specified, then the default group by is
applied). Dragging a record from one block to another change the
corresponding field to the value that represents the block. You can also
-click on the group name to edit the involved record directly.
+double-click on the group name to edit the involved record directly.
Double-click on the record to edit it. Double-click in open area to
create a new record with the group and start date linked to the area you
@@ -243,20 +243,20 @@ create a new record with the dragged start and end date.
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 the value according
- the group of the dragged item.
-- When an item label does not fit in its date-range box: ✅ the label
- correctly overflows the box; ✅ clicking anywhere on the label allows
- moving the box; ❌ double-clicking the label outside of the box does
- not open that item.
+- 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 the value
+ according the group of the dragged item.
+- When an item label does not fit in its date-range box: ✅ the label
+ correctly overflows the box; ✅ clicking anywhere on the label allows
+ moving the box; ❌ double-clicking the label outside of the box does
+ not open that item.
Bug Tracker
===========
@@ -283,28 +283,28 @@ Authors
Contributors
------------
-- Laurent Mignon
-- Adrien Peiffer
-- Leonardo Donelli
-- Adrien Didenot
-- Thong Nguyen Van
-- Murtaza Mithaiwala
-- Ammar Officewala
-- `Tecnativa `__:
+- Laurent Mignon
+- Adrien Peiffer
+- Leonardo Donelli
+- Adrien Didenot
+- Thong Nguyen Van
+- Murtaza Mithaiwala
+- Ammar Officewala
+- `Tecnativa `__:
- - Pedro M. Baeza
- - Alexandre Díaz
- - César A. Sánchez
- - Carlos López
+ - Pedro M. Baeza
+ - Alexandre Díaz
+ - César A. Sánchez
+ - Carlos López
-- `Onestein `__:
+- `Onestein `__:
- - Dennis Sluijk
- - Anjeel Haria
+ - Dennis Sluijk
+ - Anjeel Haria
-- `XCG Consulting `__:
+- `XCG Consulting `__:
- - Houzéfa Abbasbhay
+ - Houzéfa Abbasbhay
Maintainers
-----------
diff --git a/web_timeline/readme/USAGE.md b/web_timeline/readme/USAGE.md
index b07a593cb0f4..e1d7125607b5 100644
--- a/web_timeline/readme/USAGE.md
+++ b/web_timeline/readme/USAGE.md
@@ -22,7 +22,7 @@ Records are grouped in different blocks depending on the group by
criteria selected (if none is specified, then the default group by is
applied). Dragging a record from one block to another change the
corresponding field to the value that represents the block. You can also
-click on the group name to edit the involved record directly.
+double-click on the group name to edit the involved record directly.
Double-click on the record to edit it. Double-click in open area to
create a new record with the group and start date linked to the area you
diff --git a/web_timeline/static/description/index.html b/web_timeline/static/description/index.html
index 5d099eafedb0..d8d89a66b9b8 100644
--- a/web_timeline/static/description/index.html
+++ b/web_timeline/static/description/index.html
@@ -606,7 +606,7 @@
criteria selected (if none is specified, then the default group by is
applied). Dragging a record from one block to another change the
corresponding field to the value that represents the block. You can also
-click on the group name to edit the involved record directly.
+double-click on the group name to edit the involved record directly.
Double-click on the record to edit it. Double-click in open area to
create a new record with the group and start date linked to the area you
clicked in. By holding the Ctrl key and dragging left to right, you can
@@ -623,8 +623,8 @@
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 the value according
-the group of the dragged item.
+make. When the item is showed in all groups change the value
+according the group of the dragged item.
When an item label does not fit in its date-range box: ✅ the label
correctly overflows the box; ✅ clicking anywhere on the label allows
moving the box; ❌ double-clicking the label outside of the box does
diff --git a/web_timeline/static/src/views/timeline/timeline_controller.esm.js b/web_timeline/static/src/views/timeline/timeline_controller.esm.js
index 3f78edb5b13a..111f4f58a13c 100644
--- a/web_timeline/static/src/views/timeline/timeline_controller.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_controller.esm.js
@@ -63,11 +63,17 @@ export class TimelineController extends Component {
* @param {EventObject} item
*/
_onGroupClick(item) {
- const groupField = this.model.last_group_bys[0];
+ const groups = item.group.split("/");
+ const [group_key, group_value] = groups[groups.length - 1].split("-");
+ if (!group_value || group_value === "false") return;
+ // We need to get the fields metadata from the renderer
+ // since it's not available in the controller
+ const group_model = this.model.fields[group_key].relation;
+ if (!group_model) return;
this.actionService.doAction({
type: "ir.actions.act_window",
- res_model: this.model.fields[groupField].relation,
- res_id: item.group,
+ res_model: group_model,
+ res_id: parseInt(group_value, 10),
views: [[false, "form"]],
view_mode: "form",
target: "new",
@@ -82,7 +88,8 @@ export class TimelineController extends Component {
* @returns {jQuery.Deferred}
*/
_onItemDoubleClick(event) {
- return this.openItem(event.item, false);
+ const item_id = event.item.split("_")[0];
+ return this.openItem(Number(item_id) || item_id, false);
}
/**
@@ -136,10 +143,6 @@ export class TimelineController extends Component {
_onMove(item, callback) {
const event_start = DateTime.fromJSDate(item.start);
const event_end = item.end ? DateTime.fromJSDate(item.end) : false;
- let group = false;
- if (item.group !== -1) {
- group = item.group;
- }
const data = {};
// In case of a move event, the date_delay stay the same,
// only date_start and stop must be updated
@@ -159,12 +162,27 @@ export class TimelineController extends Component {
const diff = event_end.diff(event_start, "hours");
data[this.date_delay] = diff.hours;
}
- const grouped_field = this.model.last_group_bys[0];
- if (this.model.fields[grouped_field].type !== "many2many") {
- data[grouped_field] = group;
+
+ // Parse the group string to extract field values for all group levels
+ if (item.group) {
+ const group_parts = item.group.split("/");
+ for (const part of group_parts) {
+ const [groupKey, groupValue] = part.split("-");
+ // Skip m2m fields as they are complicated to handle
+ if (
+ this.model.fields[groupKey] &&
+ this.model.fields[groupKey].type !== "many2many"
+ ) {
+ data[groupKey] =
+ groupValue === "false"
+ ? false
+ : Number(groupValue) || groupValue;
+ }
+ }
}
+
this.moveQueue.push({
- id: item.id,
+ id: item.record_id,
data,
item,
callback,
@@ -204,7 +222,8 @@ export class TimelineController extends Component {
confirmLabel: _t("Confirm"),
cancelLabel: _t("Discard"),
confirm: async () => {
- await this.model.remove_completed(item);
+ // Use record_id for deletion, not the composite id
+ await this.model.remove_completed({...item, id: item.record_id});
callback(item);
},
cancel: () => {
@@ -244,8 +263,12 @@ export class TimelineController extends Component {
const diff = item_end.diff(item_start, "hours");
context[`default_${this.date_delay}`] = diff.hours;
}
- if (item.group > 0) {
- context[`default_${this.model.last_group_bys[0]}`] = item.group;
+ if (item.group) {
+ const groups = item.group.split("/");
+ for (let i = 0; i < groups.length; i++) {
+ const [group_key, group_value] = groups[i].split("-");
+ context[`default_${group_key}`] = Number(group_value) || group_value;
+ }
}
// Show popup
this.dialogService.add(
diff --git a/web_timeline/static/src/views/timeline/timeline_model.esm.js b/web_timeline/static/src/views/timeline/timeline_model.esm.js
index 3eae6d1187f1..b90510de44ac 100644
--- a/web_timeline/static/src/views/timeline/timeline_model.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_model.esm.js
@@ -75,17 +75,41 @@ export class TimelineModel extends Model {
* Transform Odoo event object to timeline event object.
*
* @param {Object} record
+ * @param {Object} fieldsGet - Field metadata for group by fields
* @private
- * @returns {Object}
+ * @returns {Object|Array} Single timeline item or array of items for m2m groups
*/
- _event_data_transform(record) {
+ _event_data_transform(record, fieldsGet) {
const [date_start, date_stop] = this._get_event_dates(record);
- let group = record[this.last_group_bys[0]];
- if (group && Array.isArray(group) && group.length > 0) {
- group = group[0];
- } else {
- group = -1;
+ const evtGroup = [];
+ let group = "undefined-false";
+ for (const grouped_field of this.last_group_bys) {
+ evtGroup.push({[grouped_field]: record[grouped_field]});
}
+
+ group = evtGroup
+ .reduce((acc, eG) => {
+ const entries = Object.entries(eG).flatMap(([f, value]) => {
+ if (value instanceof Array) {
+ if (fieldsGet && fieldsGet[f].type === "many2many") {
+ return value.length === 0
+ ? [`${f}-false`]
+ : value.map((v) => `${f}-${v}`);
+ }
+ return [`${f}-${value[0]}`];
+ } else if (["string", "number"].includes(typeof value)) {
+ return [`${f}-${value}`];
+ }
+ return [`${f}-false`];
+ });
+ if (acc.length === 0) {
+ return entries.map((e) => [e]);
+ }
+ return acc.flatMap((a) => entries.map((e) => [...a, e]));
+ }, [])
+ .map((g) => g.join("/"))
+ .join(",");
+
let colorToApply = false;
for (const color of this.colors) {
if (evaluate(color.ast, record)) {
@@ -98,21 +122,35 @@ export class TimelineModel extends Model {
content = this._render_timeline_item(record);
}
- const timeline_item = {
- start: date_start.toJSDate(),
- content: content,
- id: record.id,
- order: record.order,
- group: group,
- evt: record,
- style: `background-color: ${colorToApply};`,
- };
- // Only specify range end when there actually is one.
- // ➔ Instantaneous events / those with inverted dates are displayed as points.
- if (date_stop && DateTime.fromISO(date_start) < DateTime.fromISO(date_stop)) {
- timeline_item.end = date_stop.toJSDate();
+ const groups = group.split(",");
+ const r_list = [];
+ for (const g of groups) {
+ const r = {
+ start: date_start.toJSDate(),
+ 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: record.id + "_" + g,
+ record_id: record.id,
+ order: record.order,
+ group: g,
+ evt: record,
+ style: `background-color: ${colorToApply};`,
+ };
+ // Only specify range end when there actually is one.
+ // ➔ Instantaneous events / those with inverted dates are displayed as points.
+ if (
+ date_stop &&
+ DateTime.fromISO(date_start) < DateTime.fromISO(date_stop)
+ ) {
+ r.end = date_stop.toJSDate();
+ }
+ if (groups.length === 1) {
+ return r;
+ }
+ r_list.push(r);
}
- return timeline_item;
+ return r_list;
}
/**
* Get dates from given event
diff --git a/web_timeline/static/src/views/timeline/timeline_renderer.esm.js b/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
index 916aff6afacc..e240a83f4fa9 100644
--- a/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
@@ -13,7 +13,6 @@ import {
useState,
} from "@odoo/owl";
import {TimelineCanvas} from "./timeline_canvas.esm";
-import {_t} from "@web/core/l10n/translation";
import {loadBundle} from "@web/core/assets";
import {renderToString} from "@web/core/utils/render";
import {useService} from "@web/core/utils/hooks";
@@ -207,7 +206,7 @@ export class TimelineRenderer extends Component {
},
};
this.timeline = new vis.Timeline(this.canvasRef.el, {}, this.options);
- this.timeline.on("click", this.on_timeline_click.bind(this));
+ this.timeline.on("doubleClick", this.on_timeline_click.bind(this));
if (!this.options.onUpdate) {
// In read-only mode, catch double-clicks this way.
this.timeline.on("doubleClick", this.on_timeline_double_click.bind(this));
@@ -265,13 +264,15 @@ export class TimelineRenderer extends Component {
const keys = Object.keys(items);
for (const key of keys) {
const item = items[key];
- const data = datas.get(Number(key));
+ const data = datas.get(key);
if (!data || !data.evt) {
return;
}
for (const id of data.evt[this.dependency_arrow]) {
- if (keys.indexOf(id.toString()) !== -1) {
- this.draw_dependency(item, items[id]);
+ for (const k of keys) {
+ if (k.split("_")[0].toString() === id.toString()) {
+ this.draw_dependency(item, items[k]);
+ }
}
}
}
@@ -319,12 +320,42 @@ export class TimelineRenderer extends Component {
*/
async on_data_loaded(records, adjust_window) {
const data = [];
+ this.fieldsGet = await this.get_fields_get(this.model.last_group_bys);
for (const record of records) {
if (record[this.date_start]) {
- data.push(this.model._event_data_transform(record));
+ const transformed = this.model._event_data_transform(
+ record,
+ this.fieldsGet
+ );
+ if (Array.isArray(transformed)) {
+ data.push(...transformed);
+ } else {
+ data.push(transformed);
+ }
}
}
const groups = await this.split_groups(records);
+ this.groups = groups;
+ for (const d of data) {
+ // Check if the group should be visible
+ // If d.group is 'partner_id-35/category_id-4/city_id-false'
+ // it means there are 3 groups to check
+ // partner_id-35, partner_id-35/category_id-4, partner_id-35/category_id-4/city_id-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.data || this.mode.data === "fit";
@@ -346,50 +377,126 @@ export class TimelineRenderer extends Component {
return records;
}
const groups = [];
- groups.push({id: -1, content: _t("UNASSIGNED"), order: -1});
- var seq = 1;
- for (const evt of records) {
- const grouped_field = this.model.last_group_bys[0];
- const group_name = evt[grouped_field];
- if (group_name && group_name instanceof Array) {
- const group = groups.find(
- (existing_group) => existing_group.id === group_name[0]
- );
- if (group) {
- continue;
+ let seq = 1;
+
+ const groupLevel = this.model.last_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 [groupKey, groupValue] = group_parts[i].split("-");
+ // Skip updating m2m field as it is complicated to handle drag and drop
+ if (this.fieldsGet[groupKey].type === "many2many") {
+ continue;
+ }
+ group_record_values[groupKey] =
+ groupValue === "false"
+ ? false
+ : Number(groupValue) || groupValue;
+ }
+ group = {
+ id: subGroupId,
+ content: name || "UNASSIGNED",
+ group_record_values,
+ order: name === "UNASSIGNED" ? -1 : seq,
+ treeLevel: lvl,
+ visible: name !== "UNASSIGNED",
+ };
+ seq += 1;
+ groups.push(group);
}
- // Check if group is m2m in this case add id -> value of all
- // found entries.
- if (this.fields[grouped_field].type === "many2many") {
- const list_values = await this.get_m2m_grouping_datas(
- this.fields[grouped_field].relation,
- group_name
+ 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,
+ groupValue,
+ groupKey,
+ groupLvl,
+ parentGroups
+ ) => {
+ if (groupValue && Array.isArray(groupValue)) {
+ if (this.fieldsGet[grouped_field].type === "many2many") {
+ const groupModel = this.fieldsGet[grouped_field].relation;
+ const listValues = await this.get_m2m_grouping_datas(
+ groupModel,
+ groupValue
);
- for (const vals of list_values) {
- const is_inside = groups.some((gr) => gr.id === vals.id);
- if (!is_inside) {
- vals.order = seq;
- seq += 1;
- groups.push(vals);
+ const createdM2mGroups = [];
+ for (const vals of listValues) {
+ if (groupValue.includes(vals.id) || vals.id === false) {
+ const newM2mGroups = createGroup(
+ `${groupKey}-${vals.id}`,
+ vals.content,
+ parentGroups,
+ groupLvl
+ );
+ createdM2mGroups.push(...newM2mGroups);
}
}
- } else {
- groups.push({
- id: group_name[0],
- content: group_name[1],
- order: seq,
- });
- seq += 1;
+ return createdM2mGroups;
}
+ const groupId = `${groupKey}-${groupValue[0]}`;
+ const groupName = groupValue[1];
+ return createGroup(groupId, groupName, parentGroups, groupLvl);
+ } else if (groupValue && ["string", "number"].includes(typeof groupValue)) {
+ return createGroup(
+ `${groupKey}-${groupValue}`,
+ groupValue,
+ parentGroups,
+ groupLvl
+ );
+ }
+ return createGroup(
+ `${groupKey}-false`,
+ "UNASSIGNED",
+ parentGroups,
+ groupLvl
+ );
+ };
+
+ for (const evt of records) {
+ let parentGroups = [null];
+ for (const grouped_field of this.model.last_group_bys) {
+ const groupValue = evt[grouped_field];
+ const groupKey = grouped_field;
+ const groupLvl = groupLevel[grouped_field];
+ parentGroups = await processGroup(
+ grouped_field,
+ groupValue,
+ groupKey,
+ groupLvl,
+ parentGroups
+ );
}
}
return groups;
}
- async get_m2m_grouping_datas(model, group_name) {
- const groups = [];
- for (const gr of group_name) {
- const record_info = await this.orm.call(model, "read", [
+ async get_m2m_grouping_datas(groupModel, groupValue) {
+ const groups = [{id: false, content: "UNASSIGNED"}];
+ for (const gr of groupValue) {
+ const record_info = await this.orm.call(groupModel, "read", [
gr,
["display_name"],
]);
@@ -398,6 +505,10 @@ export class TimelineRenderer extends Component {
return groups;
}
+ async get_fields_get(group_bys) {
+ return await this.orm.call(this.model.model_name, "fields_get", [group_bys]);
+ }
+
/**
* Handle a click within the timeline.
*
@@ -417,6 +528,7 @@ export class TimelineRenderer extends Component {
* @private
*/
on_timeline_double_click(e) {
+ this.on_timeline_click(e);
if (e.what === "item" && e.item !== -1) {
this.props.onItemDoubleClick(e);
}
From af089a2d46b60a63624fb5fab8774729e55e3c16 Mon Sep 17 00:00:00 2001
From: "Cuong, Nguyen Minh Tran Manh"
Date: Fri, 13 Mar 2026 16:59:38 +0700
Subject: [PATCH 2/3] [IMP] web_timeline - support group by date/datetime
fields by year|quarter|month|week|day
Port of 16.0 commit 751fb9c to 17.0 refactored codebase:
- Convert group IDs from string format to JSON segment objects
- Add _getGroupedDate() for date/datetime grouping with Luxon
- Transform date fields after search_read for :operator specifiers
- Handle selection fields to display labels instead of technical values
- Remove fieldsGet dependency, use model.fields directly
- Add M2M name caching in split_groups
- Update controller group parsing (click, move, add) for JSON format
---
.../src/views/timeline/timeline_canvas.esm.js | 9 +-
.../views/timeline/timeline_controller.esm.js | 124 +++++--
.../src/views/timeline/timeline_model.esm.js | 200 ++++++++---
.../views/timeline/timeline_renderer.esm.js | 325 +++++++++++++-----
4 files changed, 486 insertions(+), 172 deletions(-)
diff --git a/web_timeline/static/src/views/timeline/timeline_canvas.esm.js b/web_timeline/static/src/views/timeline/timeline_canvas.esm.js
index 03ec874f1d1a..a8e7c32a97d2 100644
--- a/web_timeline/static/src/views/timeline/timeline_canvas.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_canvas.esm.js
@@ -109,17 +109,20 @@ export class TimelineCanvas {
*/
draw_line(from, to, color, width, markerStart, widthMarker, breakLineAt) {
const $from = $(from);
+ const $to = $(to);
const childPosFrom = $from.offset();
const parentPosFrom = $from.closest(".vis-center").offset();
+ const childPosTo = $to.offset();
+ const parentPosTo = $to.closest(".vis-center").offset();
+ if (!childPosFrom || !parentPosFrom || !childPosTo || !parentPosTo) {
+ return null;
+ }
const rectFrom = {
x: childPosFrom.left - parentPosFrom.left,
y: childPosFrom.top - parentPosFrom.top,
w: $from.width(),
h: $from.height(),
};
- const $to = $(to);
- const childPosTo = $to.offset();
- const parentPosTo = $to.closest(".vis-center").offset();
const rectTo = {
x: childPosTo.left - parentPosTo.left,
y: childPosTo.top - parentPosTo.top,
diff --git a/web_timeline/static/src/views/timeline/timeline_controller.esm.js b/web_timeline/static/src/views/timeline/timeline_controller.esm.js
index 111f4f58a13c..4af761c49ea8 100644
--- a/web_timeline/static/src/views/timeline/timeline_controller.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_controller.esm.js
@@ -63,21 +63,36 @@ export class TimelineController extends Component {
* @param {EventObject} item
*/
_onGroupClick(item) {
- const groups = item.group.split("/");
- const [group_key, group_value] = groups[groups.length - 1].split("-");
- if (!group_value || group_value === "false") return;
- // We need to get the fields metadata from the renderer
- // since it's not available in the controller
- const group_model = this.model.fields[group_key].relation;
- if (!group_model) return;
- this.actionService.doAction({
- type: "ir.actions.act_window",
- res_model: group_model,
- res_id: parseInt(group_value, 10),
- views: [[false, "form"]],
- view_mode: "form",
- target: "new",
- });
+ try {
+ const groupPathSegments = JSON.parse(item.group);
+ if (!Array.isArray(groupPathSegments) || groupPathSegments.length === 0)
+ return;
+
+ const lastSegment = groupPathSegments[groupPathSegments.length - 1];
+ const group_key = lastSegment.field;
+ const group_value = lastSegment.value;
+
+ if (
+ group_value === false ||
+ group_value === null ||
+ group_value === undefined
+ )
+ return;
+
+ const fieldInfo = this.model.fields[group_key];
+ if (!fieldInfo || !fieldInfo.relation) return;
+
+ this.actionService.doAction({
+ type: "ir.actions.act_window",
+ res_model: fieldInfo.relation,
+ res_id: parseInt(group_value, 10),
+ views: [[false, "form"]],
+ view_mode: "form",
+ target: "new",
+ });
+ } catch (e) {
+ console.error("Error parsing group JSON for click event:", item.group, e);
+ }
}
/**
@@ -163,21 +178,44 @@ export class TimelineController extends Component {
data[this.date_delay] = diff.hours;
}
- // Parse the group string to extract field values for all group levels
+ // Parse the group JSON to extract field values for all group levels
if (item.group) {
- const group_parts = item.group.split("/");
- for (const part of group_parts) {
- const [groupKey, groupValue] = part.split("-");
- // Skip m2m fields as they are complicated to handle
- if (
- this.model.fields[groupKey] &&
- this.model.fields[groupKey].type !== "many2many"
- ) {
- data[groupKey] =
- groupValue === "false"
- ? false
- : Number(groupValue) || groupValue;
+ try {
+ const groupPathSegments = JSON.parse(item.group);
+ if (Array.isArray(groupPathSegments)) {
+ for (const segment of groupPathSegments) {
+ // Skip m2m fields as they are complicated to handle
+ if (
+ this.model.fields[segment.field] &&
+ this.model.fields[segment.field].type === "many2many"
+ ) {
+ continue;
+ }
+ // Skip date and datetime fields
+ if (
+ this.model.fields[segment.field] &&
+ (this.model.fields[segment.field].type === "date" ||
+ this.model.fields[segment.field].type === "datetime")
+ ) {
+ continue;
+ }
+ if (
+ segment.value !== false &&
+ segment.value !== null &&
+ segment.value !== undefined
+ ) {
+ data[segment.field] = segment.value;
+ } else {
+ data[segment.field] = false;
+ }
+ }
}
+ } catch (e) {
+ console.error(
+ "Error parsing group JSON for move event:",
+ item.group,
+ e
+ );
}
}
@@ -264,10 +302,32 @@ export class TimelineController extends Component {
context[`default_${this.date_delay}`] = diff.hours;
}
if (item.group) {
- const groups = item.group.split("/");
- for (let i = 0; i < groups.length; i++) {
- const [group_key, group_value] = groups[i].split("-");
- context[`default_${group_key}`] = Number(group_value) || group_value;
+ try {
+ const groupPathSegments = JSON.parse(item.group);
+ if (Array.isArray(groupPathSegments)) {
+ groupPathSegments.forEach((segment) => {
+ // Do not set m2m fields as default context
+ if (
+ this.model.fields[segment.field] &&
+ this.model.fields[segment.field].type === "many2many"
+ ) {
+ return;
+ }
+ if (
+ segment.value !== false &&
+ segment.value !== null &&
+ segment.value !== undefined
+ ) {
+ context[`default_${segment.field}`] = Number.isInteger(
+ segment.value
+ )
+ ? segment.value
+ : segment.value;
+ }
+ });
+ }
+ } catch (e) {
+ console.error("Error parsing group JSON for add event:", item.group, e);
}
}
// Show popup
diff --git a/web_timeline/static/src/views/timeline/timeline_model.esm.js b/web_timeline/static/src/views/timeline/timeline_model.esm.js
index b90510de44ac..7334c8b9baac 100644
--- a/web_timeline/static/src/views/timeline/timeline_model.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_model.esm.js
@@ -59,61 +59,141 @@ export class TimelineModel extends Model {
} else {
this.last_group_bys = this.params.default_group_by.split(",");
}
+ // For fields to read, use base field names (strip :operator specifiers)
+ const base_group_by_names = this.last_group_bys.map((spec) =>
+ spec.includes(":") ? spec.split(":")[0] : spec
+ );
let fields = this.params.fieldNames;
- fields = [...new Set(fields.concat(this.last_group_bys))];
+ fields = [...new Set(fields.concat(base_group_by_names))];
+ // Identify date group-by specifiers that need transformation
+ const group_bys_date_fields = this.last_group_bys.filter(
+ (spec) =>
+ spec.includes(":") &&
+ (spec.includes(":year") ||
+ spec.includes(":quarter") ||
+ spec.includes(":month") ||
+ spec.includes(":week") ||
+ spec.includes(":day"))
+ );
+ // For order, use base field names
+ const order = this.params.default_group_by
+ .split(",")
+ .map((g) => (g.includes(":") ? g.split(":")[0] : g).trim())
+ .join(",");
this.data = await this.keepLast.add(
this.orm.call(this.model_name, "search_read", [], {
fields: fields,
domain: searchParams.domain,
- order: this.params.default_group_by,
+ order: order,
context: searchParams.context,
})
);
+ // Transform date fields for grouping
+ for (const d of this.data) {
+ for (const date_group of group_bys_date_fields) {
+ const base_field = date_group.split(":")[0];
+ const date_value = d[base_field];
+ if (date_value) {
+ d[date_group] = this._getGroupedDate(date_group, date_value);
+ }
+ }
+ }
this.notify();
}
/**
* Transform Odoo event object to timeline event object.
*
* @param {Object} record
- * @param {Object} fieldsGet - Field metadata for group by fields
* @private
* @returns {Object|Array} Single timeline item or array of items for m2m groups
*/
- _event_data_transform(record, fieldsGet) {
+ /* eslint-disable complexity */
+ _event_data_transform(record) {
const [date_start, date_stop] = this._get_event_dates(record);
- const evtGroup = [];
- let group = "undefined-false";
- for (const grouped_field of this.last_group_bys) {
- evtGroup.push({[grouped_field]: record[grouped_field]});
- }
+ let currentPaths = [[]];
- group = evtGroup
- .reduce((acc, eG) => {
- const entries = Object.entries(eG).flatMap(([f, value]) => {
- if (value instanceof Array) {
- if (fieldsGet && fieldsGet[f].type === "many2many") {
- return value.length === 0
- ? [`${f}-false`]
- : value.map((v) => `${f}-${v}`);
- }
- return [`${f}-${value[0]}`];
- } else if (["string", "number"].includes(typeof value)) {
- return [`${f}-${value}`];
- }
- return [`${f}-false`];
+ for (const grouped_field_spec of this.last_group_bys) {
+ const fieldValue = record[grouped_field_spec];
+ const base_grouped_field = grouped_field_spec.includes(":")
+ ? grouped_field_spec.split(":")[0]
+ : grouped_field_spec;
+ const fieldInfo = this.fields[base_grouped_field];
+ const fieldSegments = [];
+
+ if (fieldInfo.type === "many2many") {
+ const m2mIds = Array.isArray(fieldValue)
+ ? fieldValue.filter((id) => id !== false && id !== null)
+ : [];
+ if (m2mIds.length === 0) {
+ fieldSegments.push({
+ field: base_grouped_field,
+ value: false,
+ spec: grouped_field_spec,
+ });
+ } else {
+ m2mIds.forEach((valId) => {
+ fieldSegments.push({
+ field: base_grouped_field,
+ value: valId,
+ spec: grouped_field_spec,
+ });
+ });
+ }
+ } else if (
+ grouped_field_spec.includes(":") &&
+ (fieldInfo.type === "date" || fieldInfo.type === "datetime")
+ ) {
+ fieldSegments.push({
+ field: base_grouped_field,
+ value: fieldValue,
+ spec: grouped_field_spec,
+ });
+ } else if (Array.isArray(fieldValue)) {
+ fieldSegments.push({
+ field: base_grouped_field,
+ value: fieldValue[0],
+ spec: grouped_field_spec,
});
- if (acc.length === 0) {
- return entries.map((e) => [e]);
+ } else if (
+ fieldValue !== undefined &&
+ fieldValue !== null &&
+ fieldValue !== ""
+ ) {
+ if (fieldValue === false && typeof fieldValue === "boolean") {
+ fieldSegments.push({
+ field: base_grouped_field,
+ value: false,
+ spec: grouped_field_spec,
+ });
+ } else {
+ fieldSegments.push({
+ field: base_grouped_field,
+ value: fieldValue,
+ spec: grouped_field_spec,
+ });
}
- return acc.flatMap((a) => entries.map((e) => [...a, e]));
- }, [])
- .map((g) => g.join("/"))
- .join(",");
+ } else {
+ fieldSegments.push({
+ field: base_grouped_field,
+ value: false,
+ spec: grouped_field_spec,
+ });
+ }
+ currentPaths = currentPaths.flatMap((path) =>
+ fieldSegments.map((segment) => [...path, segment])
+ );
+ }
+
+ const groupJSONStrings = currentPaths.map((path) => JSON.stringify(path));
let colorToApply = false;
for (const color of this.colors) {
- if (evaluate(color.ast, record)) {
- colorToApply = color.color;
+ try {
+ if (evaluate(color.ast, record)) {
+ colorToApply = color.color;
+ }
+ } catch (e) {
+ console.warn("Error evaluating color expression:", e);
}
}
@@ -122,35 +202,30 @@ export class TimelineModel extends Model {
content = this._render_timeline_item(record);
}
- const groups = group.split(",");
const r_list = [];
- for (const g of groups) {
+ for (const jsonPathString of groupJSONStrings) {
const r = {
start: date_start.toJSDate(),
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: record.id + "_" + g,
+ // Unique ID for vis.js item
+ id: record.id + "_" + jsonPathString,
record_id: record.id,
order: record.order,
- group: g,
+ // The group this specific vis.js item belongs to
+ group: jsonPathString,
evt: record,
- style: `background-color: ${colorToApply};`,
+ style: colorToApply ? `background-color: ${colorToApply};` : "",
};
- // Only specify range end when there actually is one.
- // ➔ Instantaneous events / those with inverted dates are displayed as points.
if (
date_stop &&
DateTime.fromISO(date_start) < DateTime.fromISO(date_stop)
) {
r.end = date_stop.toJSDate();
}
- if (groups.length === 1) {
- return r;
- }
r_list.push(r);
}
- return r_list;
+ // Reset color for next event
+ return r_list.length === 1 ? r_list[0] : r_list;
}
/**
* Get dates from given event
@@ -205,6 +280,30 @@ export class TimelineModel extends Model {
return field.type === "date" ? serializeDate(value) : serializeDateTime(value);
}
+ /**
+ * Get the grouped date value for a given date, based on the group type.
+ * @param {String} date_group The date group specifier (e.g., 'date_start:month').
+ * @param {String} date_value The date value to be grouped.
+ * @returns {String|Number} The grouped date value.
+ */
+ _getGroupedDate(date_group, date_value) {
+ const field = this.fields[date_group.split(":")[0]];
+ const dt = this.parseDate(field, date_value);
+ const group_type = date_group.split(":")[1];
+ if (group_type === "year") {
+ return dt.year;
+ } else if (group_type === "quarter") {
+ return `Q${dt.quarter} ${dt.year}`;
+ } else if (group_type === "month") {
+ return dt.toFormat("MMMM yyyy");
+ } else if (group_type === "week") {
+ return `W${dt.weekNumber} ${dt.year}`;
+ } else if (group_type === "day") {
+ return dt.toFormat("dd MMM yyyy");
+ }
+ return date_value;
+ }
+
/**
* Render timeline item template.
*
@@ -243,7 +342,18 @@ export class TimelineModel extends Model {
[id],
this.params.fieldNames,
]);
- return this._event_data_transform(records[0]);
+ const record = records[0];
+ // Transform date fields for grouping (same as in load)
+ for (const spec of this.last_group_bys) {
+ if (spec.includes(":")) {
+ const base_field = spec.split(":")[0];
+ const date_value = record[base_field];
+ if (date_value) {
+ record[spec] = this._getGroupedDate(spec, date_value);
+ }
+ }
+ }
+ return this._event_data_transform(record);
}
/**
* Triggered upon completion of writing a record.
diff --git a/web_timeline/static/src/views/timeline/timeline_renderer.esm.js b/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
index e240a83f4fa9..6414eef969e5 100644
--- a/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
@@ -292,6 +292,9 @@ export class TimelineRenderer extends Component {
if (!from.displayed || !to.displayed) {
return;
}
+ if (!from.dom || !from.dom.box || !to.dom || !to.dom.box) {
+ return;
+ }
const defaults = Object.assign({line_color: "black", line_width: 1}, options);
this.canvas.draw_arrow(
from.dom.box,
@@ -320,13 +323,9 @@ export class TimelineRenderer extends Component {
*/
async on_data_loaded(records, adjust_window) {
const data = [];
- this.fieldsGet = await this.get_fields_get(this.model.last_group_bys);
for (const record of records) {
if (record[this.date_start]) {
- const transformed = this.model._event_data_transform(
- record,
- this.fieldsGet
- );
+ const transformed = this.model._event_data_transform(record);
if (Array.isArray(transformed)) {
data.push(...transformed);
} else {
@@ -336,24 +335,27 @@ export class TimelineRenderer extends Component {
}
const groups = await this.split_groups(records);
this.groups = groups;
+ // Ensure relevant groups are visible
for (const d of data) {
- // Check if the group should be visible
- // If d.group is 'partner_id-35/category_id-4/city_id-false'
- // it means there are 3 groups to check
- // partner_id-35, partner_id-35/category_id-4, partner_id-35/category_id-4/city_id-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) {
+ const itemGroupPathJson = d.group;
+ try {
+ const itemPathSegments = JSON.parse(itemGroupPathJson);
+ for (let i = 0; i < itemPathSegments.length; i++) {
+ const subPathJson = JSON.stringify(
+ itemPathSegments.slice(0, i + 1)
+ );
+ const group = groups.find((g) => g.id === subPathJson);
+ if (group && !group.visible) {
group.visible = true;
}
}
+ } catch (e) {
+ console.error(
+ "Error processing group visibility for item. Group JSON string was:",
+ itemGroupPathJson,
+ "Error:",
+ e
+ );
}
}
this.timeline.setGroups(groups);
@@ -374,7 +376,7 @@ export class TimelineRenderer extends Component {
*/
async split_groups(records) {
if (this.model.last_group_bys.length === 0) {
- return records;
+ return [];
}
const groups = [];
let seq = 1;
@@ -384,39 +386,68 @@ export class TimelineRenderer extends Component {
return acc;
}, {});
- const createGroup = (id, name, parents, lvl) => {
- const parentGroups = parents.length ? parents : [null];
+ // Memoization for M2M name_get calls
+ const m2mNameCache = {};
+ const getM2MNames = async (model, ids) => {
+ const cacheKey = `${model}-${ids.sort().join(",")}`;
+ if (m2mNameCache[cacheKey]) {
+ return m2mNameCache[cacheKey];
+ }
+ const names = await this.orm.call(model, "name_get", [ids]);
+ const result = names.map((name) => ({id: name[0], content: name[1]}));
+ m2mNameCache[cacheKey] = result;
+ return result;
+ };
+
+ const createGroup = (segmentObject, displayName, parentGroupsInput, lvl) => {
+ const parentGroups =
+ parentGroupsInput && parentGroupsInput.length
+ ? parentGroupsInput
+ : [null];
const createdGroups = [];
+
for (const parent of parentGroups) {
- const subGroupId = parent ? `${parent.id}/${id}` : id;
- let group = groups.find((g) => g.id === subGroupId);
+ let newPathSegments = [];
+ if (parent && parent.id) {
+ try {
+ const parentPathSegments = JSON.parse(parent.id);
+ newPathSegments = [...parentPathSegments, segmentObject];
+ } catch (e) {
+ console.error("Error parsing parent group ID:", parent.id, e);
+ newPathSegments = [segmentObject];
+ }
+ } else {
+ newPathSegments = [segmentObject];
+ }
+ const subGroupId = JSON.stringify(newPathSegments);
+
+ let group = groups && 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 [groupKey, groupValue] = group_parts[i].split("-");
- // Skip updating m2m field as it is complicated to handle drag and drop
- if (this.fieldsGet[groupKey].type === "many2many") {
- continue;
+ newPathSegments.forEach((segment) => {
+ // Do not set m2m fields for group_record_values
+ if (
+ this.fields[segment.field] &&
+ this.fields[segment.field].type === "many2many"
+ ) {
+ return;
}
- group_record_values[groupKey] =
- groupValue === "false"
- ? false
- : Number(groupValue) || groupValue;
- }
+ group_record_values[segment.field] = segment.value;
+ });
+
group = {
id: subGroupId,
- content: name || "UNASSIGNED",
+ content: displayName || "UNASSIGNED",
group_record_values,
- order: name === "UNASSIGNED" ? -1 : seq,
+ order: displayName === "UNASSIGNED" ? seq++ : seq++,
treeLevel: lvl,
- visible: name !== "UNASSIGNED",
+ visible: displayName !== "UNASSIGNED",
};
- seq += 1;
groups.push(group);
}
createdGroups.push(group);
- if (parent) {
+
+ if (parent && parent.id) {
if (!parent.nestedGroups) {
parent.nestedGroups = [];
}
@@ -428,47 +459,160 @@ export class TimelineRenderer extends Component {
return createdGroups;
};
+ /* eslint-disable complexity */
const processGroup = async (
- grouped_field,
+ grouped_field_spec,
groupValue,
- groupKey,
groupLvl,
parentGroups
) => {
- if (groupValue && Array.isArray(groupValue)) {
- if (this.fieldsGet[grouped_field].type === "many2many") {
- const groupModel = this.fieldsGet[grouped_field].relation;
- const listValues = await this.get_m2m_grouping_datas(
- groupModel,
- groupValue
+ const base_grouped_field = grouped_field_spec.includes(":")
+ ? grouped_field_spec.split(":")[0]
+ : grouped_field_spec;
+ const fieldInfo = this.fields[base_grouped_field];
+
+ if (fieldInfo.type === "many2many") {
+ const m2mIds = Array.isArray(groupValue)
+ ? groupValue
+ : groupValue
+ ? [groupValue]
+ : [];
+ if (m2mIds.length === 0) {
+ return createGroup(
+ {
+ field: base_grouped_field,
+ value: false,
+ spec: grouped_field_spec,
+ },
+ "UNASSIGNED",
+ parentGroups,
+ groupLvl
);
- const createdM2mGroups = [];
- for (const vals of listValues) {
- if (groupValue.includes(vals.id) || vals.id === false) {
- const newM2mGroups = createGroup(
- `${groupKey}-${vals.id}`,
- vals.content,
- parentGroups,
- groupLvl
- );
- createdM2mGroups.push(...newM2mGroups);
- }
+ }
+ const listValues = await getM2MNames(fieldInfo.relation, m2mIds);
+ const createdM2mGroups = [];
+ for (const vals of listValues) {
+ if (m2mIds.includes(vals.id)) {
+ const newM2mGroups = createGroup(
+ {
+ field: base_grouped_field,
+ value: vals.id,
+ spec: grouped_field_spec,
+ },
+ vals.content,
+ parentGroups,
+ groupLvl
+ );
+ createdM2mGroups.push(...newM2mGroups);
+ }
+ }
+ if (createdM2mGroups.length === 0 && m2mIds.length > 0) {
+ for (const id of m2mIds) {
+ const newM2mGroups = createGroup(
+ {
+ field: base_grouped_field,
+ value: id,
+ spec: grouped_field_spec,
+ },
+ `ID: ${id}`,
+ parentGroups,
+ groupLvl
+ );
+ createdM2mGroups.push(...newM2mGroups);
}
- return createdM2mGroups;
+ } else if (m2mIds.length === 0) {
+ return createGroup(
+ {
+ field: base_grouped_field,
+ value: false,
+ spec: grouped_field_spec,
+ },
+ "UNASSIGNED",
+ parentGroups,
+ groupLvl
+ );
}
- const groupId = `${groupKey}-${groupValue[0]}`;
- const groupName = groupValue[1];
- return createGroup(groupId, groupName, parentGroups, groupLvl);
- } else if (groupValue && ["string", "number"].includes(typeof groupValue)) {
+ return createdM2mGroups;
+ } else if (
+ grouped_field_spec.includes(":") &&
+ (fieldInfo.type === "date" || fieldInfo.type === "datetime")
+ ) {
+ const displayName = groupValue ? String(groupValue) : "UNASSIGNED";
return createGroup(
- `${groupKey}-${groupValue}`,
- groupValue,
+ {
+ field: base_grouped_field,
+ value: groupValue,
+ spec: grouped_field_spec,
+ },
+ displayName,
+ parentGroups,
+ groupLvl
+ );
+ } else if (Array.isArray(groupValue)) {
+ // Standard [id, name] for m2o, etc.
+ const id = groupValue[0];
+ const name = groupValue[1];
+ return createGroup(
+ {
+ field: base_grouped_field,
+ value: id,
+ spec: grouped_field_spec,
+ },
+ name,
+ parentGroups,
+ groupLvl
+ );
+ } else if (
+ groupValue !== undefined &&
+ groupValue !== null &&
+ groupValue !== false
+ ) {
+ // Simple type (string, number, boolean true)
+ if (fieldInfo.relation && typeof groupValue === "number") {
+ const names = await getM2MNames(fieldInfo.relation, [groupValue]);
+ const displayName =
+ names.length > 0 ? names[0].content : `ID: ${groupValue}`;
+ return createGroup(
+ {
+ field: base_grouped_field,
+ value: groupValue,
+ spec: grouped_field_spec,
+ },
+ displayName,
+ parentGroups,
+ groupLvl
+ );
+ }
+ // For selection fields, look up the human-readable label
+ if (fieldInfo.type === "selection" && fieldInfo.selection) {
+ const selEntry = fieldInfo.selection.find(
+ ([val]) => val === groupValue
+ );
+ const displayName = selEntry ? selEntry[1] : String(groupValue);
+ return createGroup(
+ {
+ field: base_grouped_field,
+ value: groupValue,
+ spec: grouped_field_spec,
+ },
+ displayName,
+ parentGroups,
+ groupLvl
+ );
+ }
+ return createGroup(
+ {
+ field: base_grouped_field,
+ value: groupValue,
+ spec: grouped_field_spec,
+ },
+ String(groupValue),
parentGroups,
groupLvl
);
}
return createGroup(
- `${groupKey}-false`,
+ {field: base_grouped_field, value: false, spec: grouped_field_spec},
"UNASSIGNED",
parentGroups,
groupLvl
@@ -476,39 +620,36 @@ export class TimelineRenderer extends Component {
};
for (const evt of records) {
- let parentGroups = [null];
- for (const grouped_field of this.model.last_group_bys) {
- const groupValue = evt[grouped_field];
- const groupKey = grouped_field;
- const groupLvl = groupLevel[grouped_field];
- parentGroups = await processGroup(
- grouped_field,
+ let currentParentGroups = [null];
+ for (const grouped_field_spec of this.model.last_group_bys) {
+ const groupValue = evt[grouped_field_spec];
+ const groupLvl = groupLevel[grouped_field_spec];
+ currentParentGroups = await processGroup(
+ grouped_field_spec,
groupValue,
- groupKey,
groupLvl,
- parentGroups
+ currentParentGroups
);
+ if (!currentParentGroups || currentParentGroups.length === 0) {
+ break;
+ }
}
}
+ // Ensure unique group ordering
+ groups.sort((a, b) => {
+ if (a.treeLevel !== b.treeLevel) {
+ return a.treeLevel - b.treeLevel;
+ }
+ if (a.content === "UNASSIGNED" && b.content !== "UNASSIGNED") return 1;
+ if (a.content !== "UNASSIGNED" && b.content === "UNASSIGNED") return -1;
+ if (a.content < b.content) return -1;
+ if (a.content > b.content) return 1;
+ return a.order - b.order;
+ });
+ groups.forEach((g, index) => (g.order = index + 1));
return groups;
}
- async get_m2m_grouping_datas(groupModel, groupValue) {
- const groups = [{id: false, content: "UNASSIGNED"}];
- for (const gr of groupValue) {
- const record_info = await this.orm.call(groupModel, "read", [
- gr,
- ["display_name"],
- ]);
- groups.push({id: record_info[0].id, content: record_info[0].display_name});
- }
- return groups;
- }
-
- async get_fields_get(group_bys) {
- return await this.orm.call(this.model.model_name, "fields_get", [group_bys]);
- }
-
/**
* Handle a click within the timeline.
*
From c1fafa62f228dd282350afeeab835b613b5f582f Mon Sep 17 00:00:00 2001
From: "Cuong, Nguyen Minh Tran Manh"
Date: Fri, 13 Mar 2026 17:19:25 +0700
Subject: [PATCH 3/3] [IMP] web_timeline - set m2m group value as default when
creating record
When creating a new record from a m2m group row, pre-fill the m2m field
with the group's value using the [(6, 0, [id])] command format.
---
.../src/views/timeline/timeline_controller.esm.js | 11 ++++++++++-
.../src/views/timeline/timeline_renderer.esm.js | 7 +++++--
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/web_timeline/static/src/views/timeline/timeline_controller.esm.js b/web_timeline/static/src/views/timeline/timeline_controller.esm.js
index 4af761c49ea8..bac31c9e175e 100644
--- a/web_timeline/static/src/views/timeline/timeline_controller.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_controller.esm.js
@@ -306,11 +306,20 @@ export class TimelineController extends Component {
const groupPathSegments = JSON.parse(item.group);
if (Array.isArray(groupPathSegments)) {
groupPathSegments.forEach((segment) => {
- // Do not set m2m fields as default context
+ // Set m2m fields using command format [(6, 0, [id])]
if (
this.model.fields[segment.field] &&
this.model.fields[segment.field].type === "many2many"
) {
+ if (
+ segment.value !== false &&
+ segment.value !== null &&
+ segment.value !== undefined
+ ) {
+ context[`default_${segment.field}`] = [
+ [6, 0, [segment.value]],
+ ];
+ }
return;
}
if (
diff --git a/web_timeline/static/src/views/timeline/timeline_renderer.esm.js b/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
index 6414eef969e5..a95ed1b69fa1 100644
--- a/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
@@ -393,8 +393,11 @@ export class TimelineRenderer extends Component {
if (m2mNameCache[cacheKey]) {
return m2mNameCache[cacheKey];
}
- const names = await this.orm.call(model, "name_get", [ids]);
- const result = names.map((name) => ({id: name[0], content: name[1]}));
+ const names = await this.orm.read(model, ids, ["display_name"]);
+ const result = names.map((name) => ({
+ id: name.id,
+ content: name.display_name,
+ }));
m2mNameCache[cacheKey] = result;
return result;
};