diff --git a/web_timeline/README.rst b/web_timeline/README.rst
index 3a24e9d781cd..4b4e1761a5ad 100644
--- a/web_timeline/README.rst
+++ b/web_timeline/README.rst
@@ -113,12 +113,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.
@@ -213,7 +213,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
@@ -223,20 +223,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
===========
@@ -263,28 +263,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
- `PyTech `__:
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 26b73f9a92a7..f14835939979 100644
--- a/web_timeline/static/description/index.html
+++ b/web_timeline/static/description/index.html
@@ -587,7 +587,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
@@ -604,8 +604,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_arch_parser.esm.js b/web_timeline/static/src/views/timeline/timeline_arch_parser.esm.js
index acee109cfddc..ebdc02cdce6c 100644
--- a/web_timeline/static/src/views/timeline/timeline_arch_parser.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_arch_parser.esm.js
@@ -33,7 +33,7 @@ export class TimelineArchParser {
zoomKey: "ctrlKey",
},
};
- const fieldNames = fields.display_name ? ["display_name"] : [];
+ const fieldNames = new Set(fields.display_name ? ["display_name"] : []);
visitXML(arch, (node) => {
switch (node.tagName) {
case "timeline": {
@@ -122,9 +122,7 @@ export class TimelineArchParser {
}
case "field": {
const fieldName = node.getAttribute("name");
- if (!fieldNames.includes(fieldName)) {
- fieldNames.push(fieldName);
- }
+ fieldNames.add(fieldName);
break;
}
case "t": {
@@ -142,27 +140,45 @@ export class TimelineArchParser {
"default_group_by",
"progress",
"date_delay",
- archInfo.default_group_by,
+ ...archInfo.default_group_by.split(","),
];
+ fieldsToGather
+ .map((field) =>
+ field === "default_group_by"
+ ? archInfo[field].split(",")
+ : archInfo[field]
+ )
+ .flat()
+ .filter(Boolean)
+ .forEach((field) => fieldNames.add(field));
- for (const field of fieldsToGather) {
- if (archInfo[field] && !fieldNames.includes(archInfo[field])) {
- fieldNames.push(archInfo[field]);
- }
- }
- for (const color of archInfo.colors) {
- if (!fieldNames.includes(color.field)) {
- fieldNames.push(color.field);
- }
+ archInfo.colors
+ .map((color) => color.field)
+ .forEach((field) => fieldNames.add(field));
+
+ if (archInfo.dependency_arrow) {
+ fieldNames.add(archInfo.dependency_arrow);
}
+ archInfo.fieldNames = [...fieldNames];
+
+ fieldsToGather
+ .map((field) =>
+ field === "default_group_by"
+ ? archInfo[field].split(",")
+ : archInfo[field]
+ )
+ .flat()
+ .filter(Boolean)
+ .forEach((field) => fieldNames.add(field));
+
+ archInfo.colors
+ .map((color) => color.field)
+ .forEach((field) => fieldNames.add(field));
- if (
- archInfo.dependency_arrow &&
- !fieldNames.includes(archInfo.dependency_arrow)
- ) {
- fieldNames.push(archInfo.dependency_arrow);
+ if (archInfo.dependency_arrow) {
+ fieldNames.add(archInfo.dependency_arrow);
}
- archInfo.fieldNames = fieldNames;
+ archInfo.fieldNames = [...fieldNames];
return archInfo;
}
/**
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 1292e1a59bda..00333249ec48 100644
--- a/web_timeline/static/src/views/timeline/timeline_controller.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_controller.esm.js
@@ -61,15 +61,36 @@ export class TimelineController extends Component {
* @param {EventObject} item
*/
_onGroupClick(item) {
- const groupField = this.model.last_group_bys[0];
- this.actionService.doAction({
- type: "ir.actions.act_window",
- res_model: this.model.fields[groupField].relation,
- res_id: item.group,
- 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);
+ }
}
/**
@@ -80,7 +101,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);
}
/**
@@ -134,10 +156,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
@@ -157,12 +175,50 @@ 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 JSON to extract field values for all group levels
+ if (item.group) {
+ 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
+ );
+ }
}
+
this.moveQueue.push({
- id: item.id,
+ id: item.record_id,
data,
item,
callback,
@@ -202,7 +258,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: () => {
@@ -242,8 +299,43 @@ 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) {
+ try {
+ const groupPathSegments = JSON.parse(item.group);
+ if (Array.isArray(groupPathSegments)) {
+ groupPathSegments.forEach((segment) => {
+ // 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 (
+ 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
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 1c5c3ce07b65..3d670e44b022 100644
--- a/web_timeline/static/src/views/timeline/timeline_model.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_model.esm.js
@@ -58,24 +58,46 @@ 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))];
- // Avoid ordering by many2many fields
- // because it is not supported by Odoo
- // In the module sale_timesheet_timeline, it is used
- // with default_group_by = task_user_ids
- let field_to_order = this.params.default_group_by;
- if (this.fields[field_to_order].type === "many2many") {
- field_to_order = undefined;
- }
+ 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())
+ .filter((g) => this.fields[g] && this.fields[g].type !== "many2many")
+ .join(",");
this.data = await this.keepLast.add(
this.orm.call(this.model_name, "search_read", [], {
fields: fields,
domain: searchParams.domain,
- order: field_to_order,
+ 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();
}
/**
@@ -83,20 +105,95 @@ export class TimelineModel extends Model {
*
* @param {Object} record
* @private
- * @returns {Object}
+ * @returns {Object|Array} Single timeline item or array of items for m2m groups
*/
+ /* eslint-disable complexity */
_event_data_transform(record) {
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;
+ let currentPaths = [[]];
+
+ 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,
+ });
+ } 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,
+ });
+ }
+ } 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);
}
}
@@ -105,21 +202,30 @@ 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 r_list = [];
+ for (const jsonPathString of groupJSONStrings) {
+ const r = {
+ start: date_start.toJSDate(),
+ content: content,
+ // Unique ID for vis.js item
+ id: record.id + "_" + jsonPathString,
+ record_id: record.id,
+ order: record.order,
+ // The group this specific vis.js item belongs to
+ group: jsonPathString,
+ evt: record,
+ style: colorToApply ? `background-color: ${colorToApply};` : "",
+ };
+ if (
+ date_stop &&
+ DateTime.fromISO(date_start) < DateTime.fromISO(date_stop)
+ ) {
+ r.end = date_stop.toJSDate();
+ }
+ r_list.push(r);
}
- return timeline_item;
+ // Reset color for next event
+ return r_list.length === 1 ? r_list[0] : r_list;
}
/**
* Get dates from given event
@@ -174,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.
*
@@ -212,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 29a4790c0ebd..b864dd4acb52 100644
--- a/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
@@ -12,7 +12,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";
@@ -216,7 +215,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));
@@ -276,13 +275,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]);
+ }
}
}
}
@@ -302,6 +303,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,
@@ -332,10 +336,39 @@ export class TimelineRenderer extends Component {
const data = [];
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);
+ if (Array.isArray(transformed)) {
+ data.push(...transformed);
+ } else {
+ data.push(transformed);
+ }
}
}
const groups = await this.split_groups(records);
+ this.groups = groups;
+ // Ensure relevant groups are visible
+ for (const d of data) {
+ 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);
this.timeline.setItems(data);
const mode = !this.mode.data || this.mode.data === "fit";
@@ -354,58 +387,280 @@ export class TimelineRenderer extends Component {
*/
async split_groups(records) {
if (this.model.last_group_bys.length === 0) {
- return records;
+ return [];
}
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;
- }
- // 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
- );
- 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);
- }
+ let seq = 1;
+
+ const groupLevel = this.model.last_group_bys.reduce((acc, g, index) => {
+ acc[g] = index + 1;
+ return acc;
+ }, {});
+
+ // 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.read(model, ids, ["display_name"]);
+ const result = names.map((name) => ({
+ id: name.id,
+ content: name.display_name,
+ }));
+ 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) {
+ 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 {
- groups.push({
- id: group_name[0],
- content: group_name[1],
- order: seq,
+ newPathSegments = [segmentObject];
+ }
+ const subGroupId = JSON.stringify(newPathSegments);
+
+ let group = groups && groups.find((g) => g.id === subGroupId);
+ if (!group) {
+ const group_record_values = {};
+ 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[segment.field] = segment.value;
});
- seq += 1;
+
+ group = {
+ id: subGroupId,
+ content: displayName || "UNASSIGNED",
+ group_record_values,
+ order: displayName === "UNASSIGNED" ? seq++ : seq++,
+ treeLevel: lvl,
+ visible: displayName !== "UNASSIGNED",
+ };
+ groups.push(group);
+ }
+ createdGroups.push(group);
+
+ if (parent && parent.id) {
+ if (!parent.nestedGroups) {
+ parent.nestedGroups = [];
+ }
+ if (!parent.nestedGroups.includes(group.id)) {
+ parent.nestedGroups.push(group.id);
+ }
}
}
- }
- return groups;
- }
+ return createdGroups;
+ };
- 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", [
- gr,
- ["display_name"],
- ]);
- groups.push({id: record_info[0].id, content: record_info[0].display_name});
+ /* eslint-disable complexity */
+ const processGroup = async (
+ grouped_field_spec,
+ groupValue,
+ groupLvl,
+ parentGroups
+ ) => {
+ 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 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);
+ }
+ } else if (m2mIds.length === 0) {
+ return createGroup(
+ {
+ field: base_grouped_field,
+ value: false,
+ spec: grouped_field_spec,
+ },
+ "UNASSIGNED",
+ parentGroups,
+ groupLvl
+ );
+ }
+ return createdM2mGroups;
+ } else if (
+ grouped_field_spec.includes(":") &&
+ (fieldInfo.type === "date" || fieldInfo.type === "datetime")
+ ) {
+ const displayName = groupValue ? String(groupValue) : "UNASSIGNED";
+ return createGroup(
+ {
+ 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(
+ {field: base_grouped_field, value: false, spec: grouped_field_spec},
+ "UNASSIGNED",
+ parentGroups,
+ groupLvl
+ );
+ };
+
+ for (const evt of records) {
+ 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,
+ groupLvl,
+ 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;
}
@@ -428,6 +683,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);
}