diff --git a/web_timeline/README.rst b/web_timeline/README.rst
index e45ec342e..2d7c535f6 100644
--- a/web_timeline/README.rst
+++ b/web_timeline/README.rst
@@ -135,8 +135,10 @@ rendering:
- ``record``: to access the fields values selected in the timeline
definition.
-- ``field_utils``: used to format and parse values (see available
- functions in ``web.field_utils``).
+- ``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.
@@ -159,15 +161,15 @@ More evolved example, from ``project_timeline``:
string="Tasks"
default_group_by="project_id"
event_open_popup="true"
- colors="white: user_ids == []; #2ecb71: kanban_state == 'done'; #ec7063: kanban_state == 'blocked'"
+ colors="white: user_ids == []; #2ecb71: state == '1_done'; #ec7063: state == '1_canceled'"
dependency_arrow="depend_on_ids"
>
-
+
-
+
@@ -293,6 +295,7 @@ Contributors
- Pedro M. Baeza
- Alexandre Díaz
- César A. Sánchez
+ - Carlos López
- `Onestein `__:
diff --git a/web_timeline/__manifest__.py b/web_timeline/__manifest__.py
index c070c62dc..af36c1a9b 100644
--- a/web_timeline/__manifest__.py
+++ b/web_timeline/__manifest__.py
@@ -1,10 +1,11 @@
# Copyright 2016 ACSONE SA/NV ()
+# Copyright 2024 Tecnativa - Carlos Lopez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
"name": "Web timeline",
"summary": "Interactive visualization chart to show events in time",
- "version": "16.0.1.1.5",
+ "version": "17.0.1.0.0",
"development_status": "Production/Stable",
"author": "ACSONE SA/NV, "
"Tecnativa, "
@@ -25,13 +26,24 @@
"web.assets_backend": [
"web_timeline/static/src/views/timeline/timeline_view.scss",
"web_timeline/static/src/views/timeline/timeline_canvas.scss",
+ "web_timeline/static/src/views/timeline/timeline_arch_parser.esm.js",
"web_timeline/static/src/views/timeline/timeline_view.esm.js",
"web_timeline/static/src/views/timeline/timeline_renderer.esm.js",
+ "web_timeline/static/src/views/timeline/timeline_renderer.xml",
"web_timeline/static/src/views/timeline/timeline_controller.esm.js",
+ "web_timeline/static/src/views/timeline/timeline_controller.xml",
"web_timeline/static/src/views/timeline/timeline_model.esm.js",
"web_timeline/static/src/views/timeline/timeline_canvas.esm.js",
"web_timeline/static/src/views/timeline/timeline_view.xml",
- "web_timeline/static/src/views/timeline/timeline_canvas.xml",
+ ],
+ "web_timeline.vis-timeline_lib": [
+ "/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.js",
+ "/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.css",
+ ],
+ "web.qunit_suite_tests": [
+ "web_timeline/static/tests/helpers.esm.js",
+ "web_timeline/static/tests/web_timeline_arch_parser_tests.esm.js",
+ "web_timeline/static/tests/web_timeline_view_tests.esm.js",
],
},
}
diff --git a/web_timeline/models/__init__.py b/web_timeline/models/__init__.py
index 75544cd0a..7b1f92fd1 100644
--- a/web_timeline/models/__init__.py
+++ b/web_timeline/models/__init__.py
@@ -2,3 +2,4 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from . import ir_ui_view
+from . import ir_action
diff --git a/web_timeline/models/ir_action.py b/web_timeline/models/ir_action.py
new file mode 100644
index 000000000..4328d2a55
--- /dev/null
+++ b/web_timeline/models/ir_action.py
@@ -0,0 +1,13 @@
+# Copyright 2024 Tecnativa - Carlos Lopez
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo import fields, models
+
+from .ir_ui_view import TIMELINE_VIEW
+
+
+class ActWindowView(models.Model):
+ _inherit = "ir.actions.act_window.view"
+
+ view_mode = fields.Selection(
+ selection_add=[TIMELINE_VIEW], ondelete={"timeline": "cascade"}
+ )
diff --git a/web_timeline/models/ir_ui_view.py b/web_timeline/models/ir_ui_view.py
index 43432bbb0..93e16cb27 100644
--- a/web_timeline/models/ir_ui_view.py
+++ b/web_timeline/models/ir_ui_view.py
@@ -1,4 +1,5 @@
# Copyright 2016 ACSONE SA/NV ()
+# Copyright 2024 Tecnativa - Carlos Lopez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import fields, models
@@ -10,3 +11,6 @@ class IrUIView(models.Model):
_inherit = "ir.ui.view"
type = fields.Selection(selection_add=[TIMELINE_VIEW])
+
+ def _is_qweb_based_view(self, view_type):
+ return view_type == TIMELINE_VIEW[0] or super()._is_qweb_based_view(view_type)
diff --git a/web_timeline/readme/CONFIGURE.md b/web_timeline/readme/CONFIGURE.md
index c086f4098..a130c0508 100644
--- a/web_timeline/readme/CONFIGURE.md
+++ b/web_timeline/readme/CONFIGURE.md
@@ -20,10 +20,9 @@ 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.
-- `field_utils`: used to format and parse values (see available
- functions in `web.field_utils`).
+- `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.
@@ -45,15 +44,15 @@ More evolved example, from `project_timeline`:
string="Tasks"
default_group_by="project_id"
event_open_popup="true"
- colors="white: user_ids == []; #2ecb71: kanban_state == 'done'; #ec7063: kanban_state == 'blocked'"
+ colors="white: user_ids == []; #2ecb71: state == '1_done'; #ec7063: state == '1_canceled'"
dependency_arrow="depend_on_ids"
>
-
+
-
+
diff --git a/web_timeline/readme/CONTRIBUTORS.md b/web_timeline/readme/CONTRIBUTORS.md
index 449c90ae5..1d540e015 100644
--- a/web_timeline/readme/CONTRIBUTORS.md
+++ b/web_timeline/readme/CONTRIBUTORS.md
@@ -9,6 +9,7 @@
- Pedro M. Baeza
- Alexandre Díaz
- César A. Sánchez
+ - Carlos López
- [Onestein](https://www.onestein.nl):
- Dennis Sluijk \<\>
- Anjeel Haria
diff --git a/web_timeline/static/description/index.html b/web_timeline/static/description/index.html
index facdd772d..a50e8dba3 100644
--- a/web_timeline/static/description/index.html
+++ b/web_timeline/static/description/index.html
@@ -516,8 +516,10 @@ rendering:
record : to access the fields values selected in the timeline
definition.
-field_utils : used to format and parse values (see available
-functions in web.field_utils ).
+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.
@@ -536,15 +538,15 @@ view example added onto cron tasks.
string= "Tasks"
default_group_by= "project_id"
event_open_popup= "true"
- colors= "white: user_ids == []; #2ecb71: kanban_state == 'done'; #ec7063: kanban_state == 'blocked'"
+ colors= "white: user_ids == []; #2ecb71: state == '1_done'; #ec7063: state == '1_canceled'"
dependency_arrow= "depend_on_ids"
>
<field name= "user_ids" />
- <field name= "planned_hours" />
+ <field name= "allocated_hours" />
<templates>
<t t-name= "timeline-item" >
<div class= "o_project_timeline_item" >
- <t t-foreach= "record.user_ids" t-as= "user" >
+ <t t-foreach= "record.user_ids" t-as= "user" t-key= "user.id" >
<img
t-if= "record.user_ids"
t-attf-src= "/web/image/res.users/#{user}/image_128/16x16"
@@ -559,12 +561,12 @@ view example added onto cron tasks.
<t t-esc= "record.display_name" />
</span>
<small
- name= "planned_hours"
+ name= "allocated_hours"
class= "text-info ml4"
- t-if= "record.planned_hours"
+ t-if= "record.allocated_hours"
>
<t
- t-esc= "field_utils.format.float_time(record.planned_hours)"
+ t-out= "formatters.get('float_time')(record.allocated_hours)"
/>
</small>
</div>
@@ -663,6 +665,7 @@ If you spotted it first, help us to smash it by providing a detailed and welcome
Pedro M. Baeza
Alexandre Díaz
César A. Sánchez
+Carlos López
Onestein :
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
new file mode 100644
index 000000000..a42101492
--- /dev/null
+++ b/web_timeline/static/src/views/timeline/timeline_arch_parser.esm.js
@@ -0,0 +1,191 @@
+/** @odoo-module **/
+/**
+ * Copyright 2024 Tecnativa - Carlos López
+ * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+ */
+import {_t} from "@web/core/l10n/translation";
+import {archParseBoolean} from "@web/views/utils";
+import {parseExpr} from "@web/core/py_js/py";
+import {visitXML} from "@web/core/utils/xml";
+
+const MODES = ["day", "week", "month", "fit"];
+
+export class TimelineParseArchError extends Error {}
+
+export class TimelineArchParser {
+ parse(arch, fields) {
+ const archInfo = {
+ colors: [],
+ class: "",
+ templateDocs: {},
+ min_height: 300,
+ mode: "fit",
+ canCreate: true,
+ canUpdate: true,
+ canDelete: true,
+ options: {
+ groupOrder: "order",
+ orientation: {axis: "both", item: "top"},
+ selectable: true,
+ multiselect: true,
+ showCurrentTime: true,
+ stack: true,
+ margin: {item: 2},
+ zoomKey: "ctrlKey",
+ },
+ };
+ const fieldNames = fields.display_name ? ["display_name"] : [];
+ visitXML(arch, (node) => {
+ switch (node.tagName) {
+ case "timeline": {
+ if (!node.hasAttribute("date_start")) {
+ throw new TimelineParseArchError(
+ _t("Timeline view has not defined 'date_start' attribute.")
+ );
+ }
+ if (!node.hasAttribute("default_group_by")) {
+ throw new TimelineParseArchError(
+ _t(
+ "Timeline view has not defined 'default_group_by' attribute."
+ )
+ );
+ }
+ archInfo.date_start = node.getAttribute("date_start");
+ archInfo.default_group_by = node.getAttribute("default_group_by");
+ if (node.hasAttribute("class")) {
+ archInfo.class = node.getAttribute("class");
+ }
+ if (node.hasAttribute("date_stop")) {
+ archInfo.date_stop = node.getAttribute("date_stop");
+ }
+ if (node.hasAttribute("date_delay")) {
+ archInfo.date_delay = node.getAttribute("date_delay");
+ }
+ if (node.hasAttribute("colors")) {
+ archInfo.colors = this.parse_colors(
+ node.getAttribute("colors")
+ );
+ }
+ if (node.hasAttribute("dependency_arrow")) {
+ archInfo.dependency_arrow =
+ node.getAttribute("dependency_arrow");
+ }
+ if (node.hasAttribute("stack")) {
+ archInfo.options.stack = archParseBoolean(
+ node.getAttribute("stack"),
+ true
+ );
+ }
+ if (node.hasAttribute("zoomKey")) {
+ archInfo.options.zoomKey =
+ node.getAttribute("zoomKey") || "ctrlKey";
+ }
+ if (node.hasAttribute("margin")) {
+ archInfo.options.margin = node.getAttribute("margin")
+ ? JSON.parse(node.getAttribute("margin"))
+ : {item: 2};
+ }
+ if (node.hasAttribute("min_height")) {
+ archInfo.min_height = node.getAttribute("min_height");
+ }
+ if (node.hasAttribute("mode")) {
+ archInfo.mode = node.getAttribute("mode");
+ if (!MODES.includes(archInfo.mode)) {
+ throw new TimelineParseArchError(
+ `Timeline view cannot display mode: ${archInfo.mode}`
+ );
+ }
+ }
+ if (node.hasAttribute("event_open_popup")) {
+ archInfo.open_popup_action = archParseBoolean(
+ node.getAttribute("event_open_popup")
+ );
+ }
+ if (node.hasAttribute("create")) {
+ archInfo.canCreate = archParseBoolean(
+ node.getAttribute("create"),
+ true
+ );
+ }
+ if (node.hasAttribute("edit")) {
+ archInfo.canUpdate = archParseBoolean(
+ node.getAttribute("edit"),
+ true
+ );
+ }
+ if (node.hasAttribute("delete")) {
+ archInfo.canDelete = archParseBoolean(
+ node.getAttribute("delete"),
+ true
+ );
+ }
+ break;
+ }
+ case "field": {
+ const fieldName = node.getAttribute("name");
+ if (!fieldNames.includes(fieldName)) {
+ fieldNames.push(fieldName);
+ }
+ break;
+ }
+ case "t": {
+ if (node.hasAttribute("t-name")) {
+ archInfo.templateDocs[node.getAttribute("t-name")] = node;
+ break;
+ }
+ }
+ }
+ });
+
+ const fieldsToGather = [
+ "date_start",
+ "date_stop",
+ "default_group_by",
+ "progress",
+ "date_delay",
+ archInfo.default_group_by,
+ ];
+
+ 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);
+ }
+ }
+
+ if (
+ archInfo.dependency_arrow &&
+ !fieldNames.includes(archInfo.dependency_arrow)
+ ) {
+ fieldNames.push(archInfo.dependency_arrow);
+ }
+ archInfo.fieldNames = fieldNames;
+ return archInfo;
+ }
+ /**
+ * Parse the colors attribute.
+ * @param {Array} colors
+ * @returns {Array}
+ */
+ parse_colors(colors) {
+ if (colors) {
+ return colors
+ .split(";")
+ .filter(Boolean)
+ .map((color_pair) => {
+ const [color, expr] = color_pair.split(":");
+ const ast = parseExpr(expr);
+ return {
+ color: color,
+ field: ast.left.value,
+ ast,
+ };
+ });
+ }
+ return [];
+ }
+}
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 018908ef4..03ec874f1 100644
--- a/web_timeline/static/src/views/timeline/timeline_canvas.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_canvas.esm.js
@@ -1,160 +1,146 @@
+/** @odoo-module **/
/* Copyright 2018 Onestein
+ Copyright 2024 Tecnativa - Carlos López
* License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */
-odoo.define("web_timeline.TimelineCanvas", function (require) {
- "use strict";
- const Widget = require("web.Widget");
+/**
+ * Used to draw stuff on upon the timeline view.
+ */
+
+export class TimelineCanvas {
+ constructor(canvas_ref) {
+ this.canvas_ref = canvas_ref;
+ }
+ /**
+ * Clears all drawings (svg elements) from the canvas.
+ */
+ clear() {
+ $(this.canvas_ref).find(" > :not(defs)").remove();
+ }
/**
- * Used to draw stuff on upon the timeline view.
+ * Gets the path from one point to another.
+ *
+ * @param {Object} rectFrom
+ * @param {Object} rectTo
+ * @param {Number} widthMarker The marker's width of the polyline
+ * @param {Number} breakAt The space between the line turns
+ * @returns {Array} Each item represents a coordinate
*/
- const TimelineCanvas = Widget.extend({
- template: "TimelineView.Canvas",
+ get_polyline_points(rectFrom, rectTo, widthMarker, breakAt) {
+ let fromX = 0,
+ toX = 0;
+ if (rectFrom.x < rectTo.x + rectTo.w) {
+ fromX = rectFrom.x + rectFrom.w + widthMarker;
+ toX = rectTo.x;
+ } else {
+ fromX = rectFrom.x - widthMarker;
+ toX = rectTo.x + rectTo.w;
+ }
+ let deltaBreak = 0;
+ if (fromX < toX) {
+ deltaBreak = fromX + breakAt - (toX - breakAt);
+ } else {
+ deltaBreak = fromX - breakAt - (toX + breakAt);
+ }
+ const fromHalfHeight = rectFrom.h / 2;
+ const fromY = rectFrom.y + fromHalfHeight;
+ const toHalfHeight = rectTo.h / 2;
+ const toY = rectTo.y + toHalfHeight;
+ const xDiff = fromX - toX;
+ const yDiff = fromY - toY;
+ const threshold = breakAt + widthMarker;
+ const spaceY = toHalfHeight + 2;
- /**
- * Clears all drawings (svg elements) from the canvas.
- */
- clear: function () {
- this.$(" > :not(defs)").remove();
- },
-
- /**
- * Gets the path from one point to another.
- *
- * @param {Object} rectFrom
- * @param {Object} rectTo
- * @param {Number} widthMarker The marker's width of the polyline
- * @param {Number} breakAt The space between the line turns
- * @returns {Array} Each item represents a coordinate
- */
- get_polyline_points: function (rectFrom, rectTo, widthMarker, breakAt) {
- let fromX = 0,
- toX = 0;
- if (rectFrom.x < rectTo.x + rectTo.w) {
- fromX = rectFrom.x + rectFrom.w + widthMarker;
- toX = rectTo.x;
+ const points = [[fromX, fromY]];
+ const _addPoints = (space, ePoint, mode) => {
+ if (mode) {
+ points.push([fromX + breakAt, fromY]);
+ points.push([fromX + breakAt, ePoint + space]);
+ points.push([toX - breakAt, toY + space]);
+ points.push([toX - breakAt, toY]);
} else {
- fromX = rectFrom.x - widthMarker;
- toX = rectTo.x + rectTo.w;
+ points.push([fromX - breakAt, fromY]);
+ points.push([fromX - breakAt, ePoint + space]);
+ points.push([toX + breakAt, toY + space]);
+ points.push([toX + breakAt, toY]);
}
- let deltaBreak = 0;
- if (fromX < toX) {
- deltaBreak = fromX + breakAt - (toX - breakAt);
+ };
+ if (fromY !== toY) {
+ if (Math.abs(xDiff) < threshold) {
+ points.push([fromX + breakAt, toY + yDiff]);
+ points.push([fromX + breakAt, toY]);
} else {
- deltaBreak = fromX - breakAt - (toX + breakAt);
+ const yDiffSpace = yDiff > 0 ? spaceY : -spaceY;
+ _addPoints(yDiffSpace, toY, rectFrom.x < rectTo.x + rectTo.w);
}
- const fromHalfHeight = rectFrom.h / 2;
- const fromY = rectFrom.y + fromHalfHeight;
- const toHalfHeight = rectTo.h / 2;
- const toY = rectTo.y + toHalfHeight;
- const xDiff = fromX - toX;
- const yDiff = fromY - toY;
- const threshold = breakAt + widthMarker;
- const spaceY = toHalfHeight + 2;
+ } else if (Math.abs(deltaBreak) >= threshold) {
+ _addPoints(spaceY, fromY, fromX < toX);
+ }
+ points.push([toX, toY]);
- const points = [[fromX, fromY]];
- const _addPoints = (space, ePoint, mode) => {
- if (mode) {
- points.push([fromX + breakAt, fromY]);
- points.push([fromX + breakAt, ePoint + space]);
- points.push([toX - breakAt, toY + space]);
- points.push([toX - breakAt, toY]);
- } else {
- points.push([fromX - breakAt, fromY]);
- points.push([fromX - breakAt, ePoint + space]);
- points.push([toX + breakAt, toY + space]);
- points.push([toX + breakAt, toY]);
- }
- };
- if (fromY !== toY) {
- if (Math.abs(xDiff) < threshold) {
- points.push([fromX + breakAt, toY + yDiff]);
- points.push([fromX + breakAt, toY]);
- } else {
- const yDiffSpace = yDiff > 0 ? spaceY : -spaceY;
- _addPoints(yDiffSpace, toY, rectFrom.x < rectTo.x + rectTo.w);
- }
- } else if (Math.abs(deltaBreak) >= threshold) {
- _addPoints(spaceY, fromY, fromX < toX);
- }
- points.push([toX, toY]);
+ return points;
+ }
- return points;
- },
+ /**
+ * Draws an arrow.
+ *
+ * @param {HTMLElement} from Element to draw the arrow from
+ * @param {HTMLElement} to Element to draw the arrow to
+ * @param {String} color Color of the line
+ * @param {Number} width Width of the line
+ * @returns {HTMLElement} The created SVG polyline
+ */
+ draw_arrow(from, to, color, width) {
+ return this.draw_line(from, to, color, width, "#arrowhead", 10, 12);
+ }
- /**
- * Draws an arrow.
- *
- * @param {HTMLElement} from Element to draw the arrow from
- * @param {HTMLElement} to Element to draw the arrow to
- * @param {String} color Color of the line
- * @param {Number} width Width of the line
- * @returns {HTMLElement} The created SVG polyline
- */
- draw_arrow: function (from, to, color, width) {
- return this.draw_line(from, to, color, width, "#arrowhead", 10, 12);
- },
-
- /**
- * Draws a line.
- *
- * @param {HTMLElement} from Element to draw the line from
- * @param {HTMLElement} to Element to draw the line to
- * @param {String} color Color of the line
- * @param {Number} width Width of the line
- * @param {String} markerStart Start marker of the line
- * @param {Number} widthMarker The marker's width of the polyline
- * @param {Number} breakLineAt The space between the line turns
- * @returns {HTMLElement} The created SVG polyline
- */
- draw_line: function (
- from,
- to,
- color,
- width,
- markerStart,
+ /**
+ * Draws a line.
+ *
+ * @param {HTMLElement} from Element to draw the line from
+ * @param {HTMLElement} to Element to draw the line to
+ * @param {String} color Color of the line
+ * @param {Number} width Width of the line
+ * @param {String} markerStart Start marker of the line
+ * @param {Number} widthMarker The marker's width of the polyline
+ * @param {Number} breakLineAt The space between the line turns
+ * @returns {HTMLElement} The created SVG polyline
+ */
+ draw_line(from, to, color, width, markerStart, widthMarker, breakLineAt) {
+ const $from = $(from);
+ const childPosFrom = $from.offset();
+ const parentPosFrom = $from.closest(".vis-center").offset();
+ 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,
+ w: $to.width(),
+ h: $to.height(),
+ };
+ const points = this.get_polyline_points(
+ rectFrom,
+ rectTo,
widthMarker,
breakLineAt
- ) {
- const $from = $(from);
- const childPosFrom = $from.offset();
- const parentPosFrom = $from.closest(".vis-center").offset();
- 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,
- w: $to.width(),
- h: $to.height(),
- };
- const points = this.get_polyline_points(
- rectFrom,
- rectTo,
- widthMarker,
- breakLineAt
- );
- const line = document.createElementNS(
- "http://www.w3.org/2000/svg",
- "polyline"
- );
- line.setAttribute("points", _.flatten(points).join(","));
- line.setAttribute("stroke", color || "#000");
- line.setAttribute("stroke-width", width || 1);
- line.setAttribute("fill", "none");
- if (markerStart) {
- line.setAttribute("marker-start", "url(" + markerStart + ")");
- }
- this.$el.append(line);
- return line;
- },
- });
-
- return TimelineCanvas;
-});
+ );
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
+ line.setAttribute("points", points.flat().join(","));
+ line.setAttribute("stroke", color || "#000");
+ line.setAttribute("stroke-width", width || 1);
+ line.setAttribute("fill", "none");
+ if (markerStart) {
+ line.setAttribute("marker-start", "url(" + markerStart + ")");
+ }
+ this.canvas_ref.append(line);
+ return line;
+ }
+}
diff --git a/web_timeline/static/src/views/timeline/timeline_canvas.xml b/web_timeline/static/src/views/timeline/timeline_canvas.xml
deleted file mode 100644
index 488739b16..000000000
--- a/web_timeline/static/src/views/timeline/timeline_canvas.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
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 fac8f11ed..3f78edb5b 100644
--- a/web_timeline/static/src/views/timeline/timeline_controller.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_controller.esm.js
@@ -1,106 +1,78 @@
/** @odoo-module alias=web_timeline.TimelineController **/
-/* Copyright 2023 Onestein - Anjeel Haria
- * License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */
-import AbstractController from "web.AbstractController";
+/**
+ * Copyright 2023 Onestein - Anjeel Haria
+ * Copyright 2024 Tecnativa - Carlos López
+ * License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
+ */
+import {Component, useRef} from "@odoo/owl";
+import {ConfirmationDialog} from "@web/core/confirmation_dialog/confirmation_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";
-var _t = core._t;
-import {Component} from "@odoo/owl";
+import {Layout} from "@web/search/layout";
+import {SearchBar} from "@web/search/search_bar/search_bar";
+import {_t} from "@web/core/l10n/translation";
+import {makeContext} from "@web/core/context";
+import {standardViewProps} from "@web/views/standard_view_props";
+import {useDebounced} from "@web/core/utils/timing";
+import {useModel} from "@web/model/model";
+import {useSearchBarToggler} from "@web/search/search_bar/search_bar_toggler";
+import {useService} from "@web/core/utils/hooks";
+import {useSetupView} from "@web/views/view_hook";
-export default AbstractController.extend({
- custom_events: _.extend({}, AbstractController.prototype.custom_events, {
- onGroupClick: "_onGroupClick",
- onItemDoubleClick: "_onItemDoubleClick",
- onUpdate: "_onUpdate",
- onRemove: "_onRemove",
- onMove: "_onMove",
- onAdd: "_onAdd",
- }),
+const {DateTime} = luxon;
+// Import time from "web.time";
+
+export class TimelineController extends Component {
/**
* @override
*/
- init: function (parent, model, renderer, params) {
- this._super.apply(this, arguments);
- this.open_popup_action = params.open_popup_action;
- this.date_start = params.date_start;
- this.date_stop = params.date_stop;
- this.date_delay = params.date_delay;
- this.context = params.actionContext;
+ setup() {
+ this.rootRef = useRef("root");
+ this.model = useModel(this.props.Model, this.props.modelParams);
+ useSetupView({rootRef: useRef("root")});
+ this.searchBarToggler = useSearchBarToggler();
+ this.date_start = this.props.modelParams.date_start;
+ this.date_stop = this.props.modelParams.date_stop;
+ this.date_delay = this.props.modelParams.date_delay;
+ this.open_popup_action = this.props.modelParams.open_popup_action;
this.moveQueue = [];
- this.debouncedInternalMove = _.debounce(this.internalMove, 0);
- },
- on_detach_callback() {
- if (this.Dialog) {
- this.Dialog();
- this.Dialog = undefined;
- }
- return this._super.apply(this, arguments);
- },
- /**
- * @override
- */
- update: function (params, options) {
- const res = this._super.apply(this, arguments);
- if (_.isEmpty(params)) {
- return res;
- }
- const defaults = _.defaults({}, options, {
- adjust_window: true,
- });
- const domains = params.domain || this.renderer.last_domains || [];
- const contexts = params.context || [];
- const group_bys = params.groupBy || this.renderer.last_group_bys || [];
- this.last_domains = domains;
- this.last_contexts = contexts;
- // 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(",");
- }
- this.renderer.last_group_bys = n_group_bys;
- this.renderer.last_domains = domains;
-
- let fields = this.renderer.fieldNames;
- fields = _.uniq(fields.concat(n_group_bys));
- $.when(
- res,
- this._rpc({
- model: this.model.modelName,
- method: "search_read",
- kwargs: {
- fields: fields,
- domain: domains,
- order: [{name: this.renderer.arch.attrs.default_group_by}],
- },
- context: this.getSession().user_context,
- }).then((data) =>
- this.renderer.on_data_loaded(data, n_group_bys, defaults.adjust_window)
- )
- );
- return res;
- },
-
+ this.debouncedInternalMove = useDebounced(this.internalMove, 0);
+ this.dialogService = useService("dialog");
+ this.actionService = useService("action");
+ }
+ get rendererProps() {
+ return {
+ model: this.model,
+ onAdd: this._onAdd.bind(this),
+ onGroupClick: this._onGroupClick.bind(this),
+ onItemDoubleClick: this._onItemDoubleClick.bind(this),
+ onMove: this._onMove.bind(this),
+ onRemove: this._onRemove.bind(this),
+ onUpdate: this._onUpdate.bind(this),
+ };
+ }
+ getSearchProps() {
+ const {comparision, context, domain, groupBy, orderBy} = this.env.searchModel;
+ return {comparision, context, domain, groupBy, orderBy};
+ }
/**
* Gets triggered when a group in the timeline is
* clicked (by the TimelineRenderer).
*
* @private
- * @param {EventObject} event
- * @returns {jQuery.Deferred}
+ * @param {EventObject} item
*/
- _onGroupClick: function (event) {
- const groupField = this.renderer.last_group_bys[0];
- return this.do_action({
+ _onGroupClick(item) {
+ const groupField = this.model.last_group_bys[0];
+ this.actionService.doAction({
type: "ir.actions.act_window",
- res_model: this.renderer.fields[groupField].relation,
- res_id: event.data.item.group,
- target: "new",
+ res_model: this.model.fields[groupField].relation,
+ res_id: item.group,
views: [[false, "form"]],
+ view_mode: "form",
+ target: "new",
});
- },
+ }
/**
* Triggered on double-click on an item in read-only mode (otherwise, we use _onUpdate).
@@ -109,63 +81,61 @@ export default AbstractController.extend({
* @param {EventObject} event
* @returns {jQuery.Deferred}
*/
- _onItemDoubleClick: function (event) {
- return this.openItem(event.data.item, false);
- },
+ _onItemDoubleClick(event) {
+ return this.openItem(event.item, false);
+ }
/**
* Opens a form view of a clicked timeline
* item (triggered by the TimelineRenderer).
*
* @private
- * @param {EventObject} event
+ * @param {Object} item
+ * @returns {Object}
*/
- _onUpdate: function (event) {
- const item = event.data.item;
+ _onUpdate(item) {
const item_id = Number(item.evt.id) || item.evt.id;
return this.openItem(item_id, true);
- },
+ }
- /** Open specified item, either through modal, or by navigating to form view. */
- openItem: function (item_id, is_editable) {
+ /** Open specified item, either through modal, or by navigating to form view.
+ * @param {Integer} item_id
+ * @param {Boolean} is_editable
+ */
+ openItem(item_id, is_editable) {
if (this.open_popup_action) {
const options = {
- resModel: this.model.modelName,
+ resModel: this.model.model_name,
resId: item_id,
- context: this.getSession().user_context,
};
if (is_editable) {
- options.onRecordSaved = () => this.write_completed();
+ options.onRecordSaved = async () => {
+ await this.model.load(this.getSearchProps());
+ this.render();
+ };
} else {
options.preventEdit = true;
}
- this.Dialog = Component.env.services.dialog.add(
- FormViewDialog,
- options,
- {}
- );
+ this.Dialog = this.dialogService.add(FormViewDialog, options, {});
} else {
- this.trigger_up("switch_view", {
- view_type: "form",
- model: this.model.modelName,
- res_id: item_id,
+ this.env.services.action.switchView("form", {
+ resId: item_id,
mode: is_editable ? "edit" : "readonly",
});
}
- },
+ }
/**
* Gets triggered when a timeline item is
* moved (triggered by the TimelineRenderer).
*
* @private
- * @param {EventObject} event
+ * @param {Object} item
+ * @param {Function} callback
*/
- _onMove: function (event) {
- const item = event.data.item;
- const fields = this.renderer.fields;
- const event_start = item.start;
- const event_end = item.end;
+ _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;
@@ -173,52 +143,34 @@ export default AbstractController.extend({
const 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(
- event_start,
- fields[this.date_start].type
- );
+ data[this.date_start] = this.model.serializeDate(this.date_start, event_start);
if (this.date_stop) {
// In case of instantaneous event, item.end is not defined
if (event_end) {
- data[this.date_stop] = time.auto_date_to_str(
- event_end,
- fields[this.date_stop].type
+ data[this.date_stop] = this.model.serializeDate(
+ this.date_stop,
+ event_end
);
} else {
data[this.date_stop] = data[this.date_start];
}
}
if (this.date_delay && event_end) {
- const diff_seconds = Math.round(
- (event_end.getTime() - event_start.getTime()) / 1000
- );
- data[this.date_delay] = diff_seconds / 3600;
+ const diff = event_end.diff(event_start, "hours");
+ data[this.date_delay] = diff.hours;
}
- 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,
- });
-
- this.debouncedInternalMove();
+ const grouped_field = this.model.last_group_bys[0];
+ if (this.model.fields[grouped_field].type !== "many2many") {
+ data[grouped_field] = group;
+ }
+ this.moveQueue.push({
+ id: item.id,
+ data,
+ item,
+ callback,
});
- },
-
+ this.debouncedInternalMove();
+ }
/**
* Write enqueued moves to Odoo. After all writes are finished it updates
* the view once (prevents flickering of the view when multiple timeline items
@@ -226,153 +178,96 @@ export default AbstractController.extend({
*
* @returns {jQuery.Deferred}
*/
- internalMove: function () {
+ async internalMove() {
const queues = this.moveQueue.slice();
this.moveQueue = [];
- const defers = [];
for (const item of queues) {
- defers.push(
- this._rpc({
- model: this.model.modelName,
- method: "write",
- args: [[item.event.data.item.id], item.data],
- context: this.getSession().user_context,
- }).then(() => {
- item.event.data.callback(item.event.data.item);
- })
- );
+ await this.model.write_completed(item.id, item.data);
+ item.callback(item.item);
}
- return $.when.apply($, defers).done(() => {
- this.write_completed({
- adjust_window: false,
- });
- });
- },
+ await this.model.load(this.getSearchProps());
+ this.render();
+ }
/**
* Triggered when a timeline item gets removed from the view.
* Requires user confirmation before it gets actually deleted.
*
* @private
- * @param {EventObject} event
- * @returns {jQuery.Deferred}
+ * @param {Object} item
+ * @param {Function} callback
*/
- _onRemove: function (event) {
- var def = $.Deferred();
-
- Dialog.confirm(this, _t("Are you sure you want to delete this record?"), {
+ _onRemove(item, callback) {
+ this.dialogService.add(ConfirmationDialog, {
title: _t("Warning"),
- confirm_callback: () => {
- this.remove_completed(event).then(def.resolve.bind(def));
+ body: _t("Are you sure you want to delete this record?"),
+ confirmLabel: _t("Confirm"),
+ cancelLabel: _t("Discard"),
+ confirm: async () => {
+ await this.model.remove_completed(item);
+ callback(item);
+ },
+ cancel: () => {
+ return;
},
- cancel_callback: def.resolve.bind(def),
});
-
- return def;
- },
+ }
/**
* Triggered when a timeline item gets added and opens a form view.
*
* @private
- * @param {EventObject} event
- * @returns {dialogs.FormViewDialog}
+ * @param {Object} item
+ * @param {Function} callback
*/
- _onAdd: function (event) {
- const item = event.data.item;
+ _onAdd(item, callback) {
// Initialize default values for creation
- const default_context = {};
- default_context["default_".concat(this.date_start)] = item.start;
+ const context = {};
+ let item_start = false,
+ item_end = false;
+ item_start = DateTime.fromJSDate(item.start);
+ context[`default_${this.date_start}`] = this.model.serializeDate(
+ this.date_start,
+ item_start
+ );
if (this.date_delay) {
- default_context["default_".concat(this.date_delay)] = 1;
- }
- if (this.date_start) {
- default_context["default_".concat(this.date_start)] = moment(item.start)
- .utc()
- .format("YYYY-MM-DD HH:mm:ss");
+ context[`default_${this.date_delay}`] = 1;
}
if (this.date_stop && item.end) {
- default_context["default_".concat(this.date_stop)] = moment(item.end)
- .utc()
- .format("YYYY-MM-DD HH:mm:ss");
+ item_end = DateTime.fromJSDate(item.end);
+ context[`default_${this.date_stop}`] = this.model.serializeDate(
+ this.date_stop,
+ item_end
+ );
}
- if (this.date_delay && this.date_start && this.date_stop && item.end) {
- default_context["default_".concat(this.date_delay)] =
- (moment(item.end) - moment(item.start)) / 3600000;
+ if (this.date_delay && this.date_stop && item_end) {
+ const diff = item_end.diff(item_start, "hours");
+ context[`default_${this.date_delay}`] = diff.hours;
}
if (item.group > 0) {
- default_context["default_".concat(this.renderer.last_group_bys[0])] =
- item.group;
+ context[`default_${this.model.last_group_bys[0]}`] = item.group;
}
// Show popup
- this.Dialog = Component.env.services.dialog.add(
+ this.dialogService.add(
FormViewDialog,
{
resId: false,
- context: _.extend(default_context, this.context),
- onRecordSaved: (record) => this.create_completed([record.res_id]),
- resModel: this.model.modelName,
+ context: makeContext([context], this.env.searchModel.context),
+ onRecordSaved: async (record) => {
+ const new_record = await this.model.create_completed(record.resId);
+ callback(new_record);
+ },
+ resModel: this.model.model_name,
},
- {onClose: () => event.data.callback()}
+ {onClose: () => callback()}
);
- return false;
- },
-
- /**
- * Triggered upon completion of a new record.
- * Updates the timeline view with the new record.
- *
- * @param {RecordId} id
- * @returns {jQuery.Deferred}
- */
- create_completed: function (id) {
- return this._rpc({
- model: this.model.modelName,
- method: "read",
- args: [id, this.model.fieldNames],
- context: this.context,
- }).then((records) => {
- var new_event = this.renderer.event_data_transform(records[0]);
- var items = this.renderer.timeline.itemsData;
- items.add(new_event);
- });
- },
-
- /**
- * Triggered upon completion of writing a record.
- * @param {ControllerOptions} options
- */
- write_completed: function (options) {
- const params = {
- domain: this.renderer.last_domains,
- context: this.context,
- groupBy: this.renderer.last_group_bys,
- };
- this.update(params, options);
- },
-
- /**
- * Triggered upon confirm of removing a record.
- * @param {EventObject} event
- * @returns {jQuery.Deferred}
- */
- remove_completed: function (event) {
- return this._rpc({
- model: this.modelName,
- method: "unlink",
- args: [[event.data.item.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) {
- unlink_index = i;
- }
- }
- if (!isNaN(unlink_index)) {
- this.model.data.data.splice(unlink_index, 1);
- }
- event.data.callback(event.data.item);
- });
- },
-});
+ }
+}
+TimelineController.template = "web_timeline.TimelineView";
+TimelineController.components = {Layout, SearchBar};
+TimelineController.props = {
+ ...standardViewProps,
+ Model: Function,
+ modelParams: Object,
+ Renderer: Function,
+};
diff --git a/web_timeline/static/src/views/timeline/timeline_controller.xml b/web_timeline/static/src/views/timeline/timeline_controller.xml
new file mode 100644
index 000000000..5b454fe60
--- /dev/null
+++ b/web_timeline/static/src/views/timeline/timeline_controller.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 f67ce82ae..3eae6d118 100644
--- a/web_timeline/static/src/views/timeline/timeline_model.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_model.esm.js
@@ -1,77 +1,227 @@
-odoo.define("web_timeline.TimelineModel", function (require) {
- "use strict";
+/** @odoo-module **/
+/**
+ * Copyright 2024 Tecnativa - Carlos López
+ * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+ */
+import {serializeDate, serializeDateTime} from "@web/core/l10n/dates";
+import {KanbanCompiler} from "@web/views/kanban/kanban_compiler";
+import {KeepLast} from "@web/core/utils/concurrency";
+import {Model} from "@web/model/model";
+import {evaluate} from "@web/core/py_js/py";
+import {onWillStart} from "@odoo/owl";
+import {registry} from "@web/core/registry";
+import {renderToString} from "@web/core/utils/render";
+import {useViewCompiler} from "@web/views/view_compiler";
- const AbstractModel = require("web.AbstractModel");
+const {DateTime} = luxon;
+const parsers = registry.category("parsers");
+const formatters = registry.category("formatters");
- const TimelineModel = AbstractModel.extend({
- init: function () {
- this._super.apply(this, arguments);
- },
+export class TimelineModel extends Model {
+ setup(params) {
+ this.params = params;
+ this.model_name = params.resModel;
+ this.fields = this.params.fields;
+ this.date_start = this.params.date_start;
+ this.date_stop = this.params.date_stop;
+ this.date_delay = this.params.date_delay;
+ this.colors = this.params.colors;
+ this.last_group_bys = this.params.default_group_by.split(",");
+ const templates = useViewCompiler(KanbanCompiler, this.params.templateDocs);
+ this.recordTemplate = templates["timeline-item"];
- load: function (params) {
- this.modelName = params.modelName;
- this.fieldNames = params.fieldNames;
- this.default_group_by = params.default_group_by;
- if (!this.preload_def) {
- this.preload_def = $.Deferred();
- $.when(
- this._rpc({
- model: this.modelName,
- method: "check_access_rights",
- args: ["write", false],
- }),
- this._rpc({
- model: this.modelName,
- method: "check_access_rights",
- args: ["unlink", false],
- }),
- this._rpc({
- model: this.modelName,
- method: "check_access_rights",
- args: ["create", false],
- })
- ).then((write, unlink, create) => {
- this.write_right = write;
- this.unlink_right = unlink;
- this.create_right = create;
- this.preload_def.resolve();
- });
+ this.keepLast = new KeepLast();
+ onWillStart(async () => {
+ this.write_right = await this.orm.call(
+ this.model_name,
+ "check_access_rights",
+ ["write", false]
+ );
+ this.unlink_right = await this.orm.call(
+ this.model_name,
+ "check_access_rights",
+ ["unlink", false]
+ );
+ this.create_right = await this.orm.call(
+ this.model_name,
+ "check_access_rights",
+ ["create", false]
+ );
+ });
+ }
+ /**
+ * Read the records for the timeline.
+ * @param {Object} searchParams
+ */
+ async load(searchParams) {
+ if (searchParams.groupBy && searchParams.groupBy.length) {
+ this.last_group_bys = searchParams.groupBy;
+ } else {
+ this.last_group_bys = this.params.default_group_by.split(",");
+ }
+ let fields = this.params.fieldNames;
+ fields = [...new Set(fields.concat(this.last_group_bys))];
+ 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,
+ context: searchParams.context,
+ })
+ );
+ this.notify();
+ }
+ /**
+ * Transform Odoo event object to timeline event object.
+ *
+ * @param {Object} record
+ * @private
+ * @returns {Object}
+ */
+ _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 colorToApply = false;
+ for (const color of this.colors) {
+ if (evaluate(color.ast, record)) {
+ colorToApply = color.color;
}
+ }
- this.data = {
- domain: params.domain,
- context: params.context,
- };
+ let content = record.display_name;
+ if (this.recordTemplate) {
+ content = this._render_timeline_item(record);
+ }
- return this.preload_def.then(this._loadTimeline.bind(this));
- },
+ 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();
+ }
+ return timeline_item;
+ }
+ /**
+ * Get dates from given event
+ *
+ * @param {Object} record
+ * @returns {Object}
+ */
+ _get_event_dates(record) {
+ let date_start = DateTime.now();
+ let date_stop = null;
+ const date_delay = record[this.date_delay] || false;
+ date_start = this.parseDate(
+ this.fields[this.date_start],
+ record[this.date_start]
+ );
+ if (this.date_stop && record[this.date_stop]) {
+ date_stop = this.parseDate(
+ this.fields[this.date_stop],
+ record[this.date_stop]
+ );
+ }
+ if (!date_stop && date_delay) {
+ date_stop = date_start.plus({hours: date_delay});
+ }
- /**
- * Read the records for the timeline.
- *
- * @private
- * @returns {jQuery.Deferred}
- */
- _loadTimeline: function () {
- return this._rpc({
- model: this.modelName,
- method: "search_read",
- kwargs: {
- fields: this.fieldNames,
- domain: this.data.domain,
- order: [{name: this.default_group_by}],
- context: this.data.context,
- },
- }).then((events) => {
- this.data.data = events;
- this.data.rights = {
- unlink: this.unlink_right,
- create: this.create_right,
- write: this.write_right,
- };
- });
- },
- });
+ return [date_start, date_stop];
+ }
+ /**
+ * Parse Date or DateTime field
+ *
+ * @param {Object} field
+ * @param {Object} value
+ * @returns {DateTime} new_date in UTC timezone if field is datetime
+ */
+ parseDate(field, value) {
+ let new_date = parsers.get(field.type)(value);
+ if (field.type === "datetime") {
+ new_date = new_date.setZone("UTC", {keepLocalTime: true});
+ }
+ return new_date;
+ }
- return TimelineModel;
-});
+ /**
+ * Serializes a date or datetime value based on the field type.
+ * to send it to the server.
+ * @param {String} field_name - The field name.
+ * @param {DateTime} value - The value to be serialized, either a date or datetime.
+ * @returns {String} - The serialized date or datetime string.
+ */
+ serializeDate(field_name, value) {
+ const field = this.fields[field_name];
+ return field.type === "date" ? serializeDate(value) : serializeDateTime(value);
+ }
+
+ /**
+ * Render timeline item template.
+ *
+ * @param {Object} record Record
+ * @private
+ * @returns {String} Rendered template
+ */
+ _render_timeline_item(record) {
+ return renderToString(this.recordTemplate, {
+ record: record,
+ formatters,
+ parsers,
+ });
+ }
+ /**
+ * Triggered upon confirm of removing a record.
+ * @param {EventObject} event
+ * @returns {jQuery.Deferred}
+ */
+ async remove_completed(event) {
+ await this.orm.call(this.model_name, "unlink", [[event.evt.id]]);
+ const unlink_index = this.data.findIndex((item) => item.id === event.evt.id);
+ if (unlink_index !== -1) {
+ this.data.splice(unlink_index, 1);
+ }
+ }
+ /**
+ * Triggered upon completion of a new record.
+ * Updates the timeline view with the new record.
+ *
+ * @param {RecordId} id
+ * @returns {jQuery.Deferred}
+ */
+ async create_completed(id) {
+ const records = await this.orm.call(this.model_name, "read", [
+ [id],
+ this.params.fieldNames,
+ ]);
+ return this._event_data_transform(records[0]);
+ }
+ /**
+ * Triggered upon completion of writing a record.
+ * @param {Integer} id
+ * @param {Object} vals
+ */
+ async write_completed(id, vals) {
+ return this.orm.call(this.model_name, "write", [id, vals]);
+ }
+ get canCreate() {
+ return this.params.canCreate && this.create_right;
+ }
+ get canDelete() {
+ return this.params.canDelete && this.unlink_right;
+ }
+ get canEdit() {
+ return this.params.canUpdate && this.write_right;
+ }
+}
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 aba23f760..c64000c2f 100644
--- a/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_renderer.esm.js
@@ -1,663 +1,450 @@
-/* global vis, py */
-odoo.define("web_timeline.TimelineRenderer", function (require) {
- "use strict";
+/** @odoo-module **/
+/* global vis */
+/**
+ * Copyright 2024 Tecnativa - Carlos López
+ * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+ */
+import {
+ Component,
+ onMounted,
+ onWillStart,
+ onWillUpdateProps,
+ useRef,
+ 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";
- const AbstractRenderer = require("web.AbstractRenderer");
- const core = require("web.core");
- const time = require("web.time");
- const utils = require("web.utils");
- const session = require("web.session");
- const QWeb = require("web.QWeb");
- const field_utils = require("web.field_utils");
- const TimelineCanvas = require("web_timeline.TimelineCanvas");
+const {DateTime} = luxon;
- const _t = core._t;
-
- const TimelineRenderer = AbstractRenderer.extend({
- template: "TimelineView",
-
- events: _.extend({}, AbstractRenderer.prototype.events, {
- "click .oe_timeline_button_today": "_onTodayClicked",
- "click .oe_timeline_button_scale_day": "_onScaleDayClicked",
- "click .oe_timeline_button_scale_week": "_onScaleWeekClicked",
- "click .oe_timeline_button_scale_month": "_onScaleMonthClicked",
- "click .oe_timeline_button_scale_year": "_onScaleYearClicked",
- }),
-
- init: function (parent, state, params) {
- this._super.apply(this, arguments);
- this.modelName = params.model;
- this.mode = params.mode;
- this.options = params.options;
- this.can_create = params.can_create;
- this.can_update = params.can_update;
- this.can_delete = params.can_delete;
- this.min_height = params.min_height;
- this.date_start = params.date_start;
- this.date_stop = params.date_stop;
- this.date_delay = params.date_delay;
- this.colors = params.colors;
- this.fieldNames = params.fieldNames;
- this.default_group_by = params.default_group_by;
- this.dependency_arrow = params.dependency_arrow;
- this.modelClass = params.view.model;
- this.fields = params.fields;
-
- this.timeline = false;
- this.initial_data_loaded = false;
- },
-
- /**
- * @override
- */
- start: function () {
- const attrs = this.arch.attrs;
- this.$el.addClass(attrs.class);
- this.$timeline = this.$(".oe_timeline_widget");
-
- if (!this.date_start) {
- throw new Error(
- _t("Timeline view has not defined 'date_start' attribute.")
- );
+export class TimelineRenderer extends Component {
+ setup() {
+ this.orm = useService("orm");
+ this.rootRef = useRef("root");
+ this.canvasRef = useRef("canvas");
+ this.model = this.props.model;
+ this.params = this.model.params;
+ this.mode = useState({data: this.params.mode});
+ this.options = this.params.options;
+ this.min_height = this.params.min_height;
+ this.date_start = this.params.date_start;
+ this.dependency_arrow = this.params.dependency_arrow;
+ this.fields = this.params.fields;
+ this.timeline = false;
+ this.initial_data_loaded = false;
+ this.canvas_ref = $(renderToString("TimelineView.Canvas", {}));
+ onWillUpdateProps(async (props) => {
+ this.on_data_loaded(props.model.data);
+ });
+ onWillStart(async () => {
+ await loadBundle("web_timeline.vis-timeline_lib");
+ });
+ onMounted(() => {
+ // Prevent Double Rendering on Updates
+ if (!this.timeline) {
+ this.init_timeline();
}
- this._super.apply(this, arguments);
- },
+ this.on_attach_callback();
+ });
+ }
- /**
- * Triggered when the timeline is attached to the DOM.
- */
- on_attach_callback: function () {
- const height =
- this.$el.parent().height() - this.$(".oe_timeline_buttons").height();
- if (height > this.min_height && this.timeline) {
- this.timeline.setOptions({
- height: height,
- });
- }
- },
-
- /**
- * @override
- */
- _render: function () {
- return Promise.resolve().then(() => {
- // Prevent Double Rendering on Updates
- if (!this.timeline) {
- this.init_timeline();
- }
+ /**
+ * Triggered when the timeline is attached to the DOM.
+ */
+ on_attach_callback() {
+ const $root = $(this.rootRef.el);
+ $root.addClass(this.params.class);
+ const height =
+ $root.parent().height() - $root.find(".oe_timeline_buttons").height();
+ if (height > this.min_height && this.timeline) {
+ this.timeline.setOptions({
+ height: height,
});
- },
-
- /**
- * Set the timeline window to today (day).
- *
- * @private
- */
- _onTodayClicked: function () {
- if (this.timeline) {
- this.timeline.setWindow({
- start: new moment(),
- end: new moment().add(24, "hours"),
- });
- }
- },
-
- /**
- * Scale the timeline window to a day.
- *
- * @private
- */
- _onScaleDayClicked: function () {
- this._scaleCurrentWindow(() => 24);
- },
-
- /**
- * Scale the timeline window to a week.
- *
- * @private
- */
- _onScaleWeekClicked: function () {
- this._scaleCurrentWindow(() => 24 * 7);
- },
-
- /**
- * Scale the timeline window to a month.
- *
- * @private
- */
- _onScaleMonthClicked: function () {
- this._scaleCurrentWindow((start) => 24 * moment(start).daysInMonth());
- },
-
- /**
- * Scale the timeline window to a year.
- *
- * @private
- */
- _onScaleYearClicked: function () {
- this._scaleCurrentWindow(
- (start) => 24 * (moment(start).isLeapYear() ? 366 : 365)
- );
- },
-
- /**
- * Scales the timeline window based on the current window.
- *
- * @param {function} getHoursFromStart Function which returns the timespan
- * (in hours) the window must be scaled to, starting from the "start" moment.
- * @private
- */
- _scaleCurrentWindow: function (getHoursFromStart) {
- if (this.timeline) {
- const start = this.timeline.getWindow().start;
- const end = moment(start).add(getHoursFromStart(start), "hours");
- this.timeline.setWindow(start, end);
- }
- },
-
- /**
- * Computes the initial visible window.
- *
- * @private
- */
- _computeMode: function () {
- if (this.mode) {
- let start = false,
- end = false;
- switch (this.mode) {
- case "day":
- start = new moment().startOf("day");
- end = new moment().endOf("day");
- break;
- case "week":
- start = new moment().startOf("week");
- end = new moment().endOf("week");
- break;
- case "month":
- start = new moment().startOf("month");
- end = new moment().endOf("month");
- break;
- }
- if (end && start) {
- this.options.start = start;
- this.options.end = end;
- } else {
- this.mode = "fit";
- }
- }
- },
-
- /**
- * Initializes the timeline
- * (https://visjs.github.io/vis-timeline/docs/timeline).
- *
- * @private
- */
- init_timeline: function () {
- this._computeMode();
- this.options.editable = {};
- if (this.can_update && this.modelClass.data.rights.write) {
- this.options.onMove = this.on_move;
- this.options.onUpdate = this.on_update;
- // Drag items horizontally
- this.options.editable.updateTime = true;
- // Drag items from one group to another
- this.options.editable.updateGroup = true;
- if (this.can_create && this.modelClass.data.rights.create) {
- this.options.onAdd = this.on_add;
- // Add new items by double tapping
- this.options.editable.add = true;
- }
- }
- if (this.can_delete && this.modelClass.data.rights.unlink) {
- this.options.onRemove = this.on_remove;
- // Delete an item by tapping the delete button top right
- this.options.editable.remove = true;
- }
- this.options.xss = {disabled: true};
- this.qweb = new QWeb(session.debug, {_s: session.origin}, false);
- if (this.arch.children.length) {
- const tmpl = utils.json_node_to_xml(
- _.filter(this.arch.children, (item) => item.tag === "templates")[0]
- );
- this.qweb.add_template(tmpl);
- }
-
- this.timeline = new vis.Timeline(this.$timeline.get(0), {}, this.options);
- this.timeline.on("click", this.on_timeline_click);
- if (!this.options.onUpdate) {
- // In read-only mode, catch double-clicks this way.
- this.timeline.on("doubleClick", this.on_timeline_double_click);
- }
- const group_bys = this.arch.attrs.default_group_by.split(",");
- this.last_group_bys = group_bys;
- this.last_domains = this.modelClass.data.domain;
- this.$centerContainer = $(this.timeline.dom.centerContainer);
- this.canvas = new TimelineCanvas(this);
- this.canvas.appendTo(this.$centerContainer);
- this.timeline.on("changed", () => {
- this.draw_canvas();
- this.load_initial_data();
+ }
+ }
+ /**
+ * Set the timeline window to today (day).
+ *
+ * @private
+ */
+ _onTodayClicked() {
+ this.mode.data = "today";
+ if (this.timeline) {
+ this.timeline.setWindow({
+ start: DateTime.now().toJSDate(),
+ end: DateTime.now().plus({hours: 24}).toJSDate(),
});
- },
+ }
+ }
- /**
- * Clears and draws the canvas items.
- *
- * @private
- */
- draw_canvas: function () {
- this.canvas.clear();
- if (this.dependency_arrow) {
- this.draw_dependencies();
+ /**
+ * Scale the timeline window to a day.
+ *
+ * @private
+ */
+ _onScaleDayClicked() {
+ this.mode.data = "day";
+ this._scaleCurrentWindow(() => 24);
+ }
+
+ /**
+ * Scale the timeline window to a week.
+ *
+ * @private
+ */
+ _onScaleWeekClicked() {
+ this.mode.data = "week";
+ this._scaleCurrentWindow(() => 24 * 7);
+ }
+
+ /**
+ * Scale the timeline window to a month.
+ *
+ * @private
+ */
+ _onScaleMonthClicked() {
+ this.mode.data = "month";
+ this._scaleCurrentWindow((start) => 24 * start.daysInMonth);
+ }
+
+ /**
+ * Scale the timeline window to a year.
+ *
+ * @private
+ */
+ _onScaleYearClicked() {
+ this.mode.data = "year";
+ this._scaleCurrentWindow((start) => 24 * (start.isInLeapYear ? 366 : 365));
+ }
+
+ /**
+ * Scales the timeline window based on the current window.
+ *
+ * @param {function} getHoursFromStart Function which returns the timespan
+ * (in hours) the window must be scaled to, starting from the "start" moment.
+ * @private
+ */
+ _scaleCurrentWindow(getHoursFromStart) {
+ if (this.timeline) {
+ const start = DateTime.fromJSDate(this.timeline.getWindow().start);
+ const end = start.plus({hours: getHoursFromStart(start)});
+ this.timeline.setWindow(start.toJSDate(), end.toJSDate());
+ }
+ }
+
+ /**
+ * Computes the initial visible window.
+ *
+ * @private
+ */
+ _computeMode() {
+ if (this.mode.data) {
+ let start = false,
+ end = false;
+ const current_date = DateTime.now();
+ switch (this.mode.data) {
+ case "day":
+ start = current_date.startOf("day");
+ end = current_date.endOf("day");
+ break;
+ case "week":
+ start = current_date.startOf("week");
+ end = current_date.endOf("week");
+ break;
+ case "month":
+ start = current_date.startOf("month");
+ end = current_date.endOf("month");
+ break;
}
- },
+ if (end && start) {
+ this.options.start = start.toJSDate();
+ this.options.end = end.toJSDate();
+ } else {
+ this.mode.data = "fit";
+ }
+ }
+ }
- /**
- * Draw item dependencies on canvas.
- *
- * @private
- */
- draw_dependencies: function () {
- const items = this.timeline.itemSet.items;
- const datas = this.timeline.itemsData;
- if (!items || !datas) {
+ /**
+ * Initializes the timeline
+ * (https://visjs.github.io/vis-timeline/docs/timeline).
+ *
+ * @private
+ */
+ init_timeline() {
+ this._computeMode();
+ this.options.editable = {};
+ if (this.model.canEdit) {
+ this.options.onMove = this.on_move.bind(this);
+ this.options.onUpdate = this.on_update.bind(this);
+ // Drag items horizontally
+ this.options.editable.updateTime = true;
+ // Drag items from one group to another
+ this.options.editable.updateGroup = true;
+ if (this.model.canCreate) {
+ this.options.onAdd = this.on_add.bind(this);
+ // Add new items by double tapping
+ this.options.editable.add = true;
+ }
+ }
+ if (this.model.canDelete) {
+ this.options.onRemove = this.on_remove.bind(this);
+ // Delete an item by tapping the delete button top right
+ this.options.editable.remove = true;
+ }
+ this.options.xss = {disabled: true};
+ this.timeline = new vis.Timeline(this.canvasRef.el, {}, this.options);
+ this.timeline.on("click", 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));
+ }
+ this.$centerContainer = $(this.timeline.dom.centerContainer);
+ this.canvas = new TimelineCanvas(this.canvas_ref);
+ this.canvas_ref.appendTo(this.$centerContainer);
+ this.timeline.on("changed", () => {
+ this.draw_canvas();
+ this.load_initial_data();
+ });
+ }
+
+ /**
+ * Clears and draws the canvas items.
+ *
+ * @private
+ */
+ draw_canvas() {
+ this.canvas.clear();
+ if (this.dependency_arrow) {
+ this.draw_dependencies();
+ }
+ }
+
+ /**
+ * Draw item dependencies on canvas.
+ *
+ * @private
+ */
+ draw_dependencies() {
+ const items = this.timeline.itemSet.items;
+ const datas = this.timeline.itemsData;
+ if (!items || !datas) {
+ return;
+ }
+ const keys = Object.keys(items);
+ for (const key of keys) {
+ const item = items[key];
+ const data = datas.get(Number(key));
+ if (!data || !data.evt) {
return;
}
- const keys = Object.keys(items);
- for (const key of keys) {
- const item = items[key];
- const data = datas.get(Number(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 id of data.evt[this.dependency_arrow]) {
+ if (keys.indexOf(id.toString()) !== -1) {
+ this.draw_dependency(item, items[id]);
}
}
- },
+ }
+ }
- /**
- * Draws a dependency arrow between 2 timeline items.
- *
- * @param {Object} from Start timeline item
- * @param {Object} to Destination timeline item
- * @param {Object} options
- * @param {Object} options.line_color Color of the line
- * @param {Object} options.line_width The width of the line
- * @private
- */
- draw_dependency: function (from, to, options) {
- if (!from.displayed || !to.displayed) {
- return;
+ /**
+ * Draws a dependency arrow between 2 timeline items.
+ *
+ * @param {Object} from Start timeline item
+ * @param {Object} to Destination timeline item
+ * @param {Object} options
+ * @param {Object} options.line_color Color of the line
+ * @param {Object} options.line_width The width of the line
+ * @private
+ */
+ draw_dependency(from, to, options) {
+ if (!from.displayed || !to.displayed) {
+ return;
+ }
+ const defaults = Object.assign({line_color: "black", line_width: 1}, options);
+ this.canvas.draw_arrow(
+ from.dom.box,
+ to.dom.box,
+ defaults.line_color,
+ defaults.line_width
+ );
+ }
+
+ /* Load initial data. This is called once after each redraw; we only handle the first one.
+ * Deferring this initial load here avoids rendering issues. */
+ load_initial_data() {
+ if (!this.initial_data_loaded) {
+ this.on_data_loaded(this.model.data);
+ this.initial_data_loaded = true;
+ this.timeline.redraw();
+ }
+ }
+
+ /**
+ * Set groups and events.
+ *
+ * @param {Object[]} records
+ * @param {Boolean} adjust_window
+ * @private
+ */
+ async on_data_loaded(records, adjust_window) {
+ const data = [];
+ for (const record of records) {
+ if (record[this.date_start]) {
+ data.push(this.model._event_data_transform(record));
}
- const defaults = _.defaults({}, options, {
- line_color: "black",
- line_width: 1,
- });
- this.canvas.draw_arrow(
- from.dom.box,
- to.dom.box,
- defaults.line_color,
- defaults.line_width
- );
- },
+ }
+ const groups = await this.split_groups(records);
+ this.timeline.setGroups(groups);
+ this.timeline.setItems(data);
+ const mode = !this.mode.data || this.mode.data === "fit";
+ const adjust = typeof adjust_window === "undefined" || adjust_window;
+ if (mode && adjust) {
+ this.timeline.fit();
+ }
+ }
- /* Load initial data. This is called once after each redraw; we only handle the first one.
- * Deferring this initial load here avoids rendering issues. */
- load_initial_data: function () {
- if (!this.initial_data_loaded) {
- this.on_data_loaded(this.modelClass.data.data, this.last_group_bys);
- this.initial_data_loaded = true;
- this.timeline.redraw();
- }
- },
-
- /**
- * Load display_name of records.
- *
- * @param {Object[]} events
- * @param {String[]} group_bys
- * @param {Boolean} adjust_window
- * @private
- * @returns {jQuery.Deferred}
- */
- on_data_loaded: function (events, group_bys, adjust_window) {
- const ids = _.pluck(events, "id");
- return this._rpc({
- model: this.modelName,
- method: "name_get",
- args: [ids],
- context: this.getSession().user_context,
- }).then((names) => {
- const nevents = _.map(events, (event) =>
- _.extend(
- {
- __name: _.detect(names, (name) => name[0] === event.id)[1],
- },
- event
- )
+ /**
+ * Get the groups.
+ *
+ * @param {Object[]} records
+ * @private
+ * @returns {Array}
+ */
+ async split_groups(records) {
+ if (this.model.last_group_bys.length === 0) {
+ 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]
);
- return this.on_data_loaded_2(nevents, group_bys, adjust_window);
- });
- },
-
- /**
- * Set groups and events.
- *
- * @param {Object[]} events
- * @param {String[]} group_bys
- * @param {Boolean} adjust_window
- * @private
- */
- on_data_loaded_2: function (events, group_bys, adjust_window) {
- const data = [];
- this.grouped_by = group_bys;
- for (const evt of events) {
- if (evt[this.date_start]) {
- data.push(this.event_data_transform(evt));
+ if (group) {
+ continue;
}
- }
- this.split_groups(events, group_bys).then((groups) => {
- this.timeline.setGroups(groups);
- this.timeline.setItems(data);
- const mode = !this.mode || this.mode === "fit";
- const adjust = _.isUndefined(adjust_window) || adjust_window;
- if (mode && adjust) {
- this.timeline.fit();
- }
- });
- },
-
- /**
- * Get the groups.
- *
- * @param {Object[]} events
- * @param {String[]} group_bys
- * @private
- * @returns {Array}
- */
- split_groups: async function (events, group_bys) {
- if (group_bys.length === 0) {
- 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]
- );
- 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;
- }
- });
+ // 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);
}
}
- }
- }
- return groups;
- },
-
- get_m2m_grouping_datas: async function (model, group_name) {
- const groups = [];
- for (const gr of group_name) {
- await this._rpc({
- model: model,
- method: "name_get",
- args: [gr],
- context: this.getSession().user_context,
- }).then((name) => {
- groups.push({id: name[0][0], content: name[0][1]});
- });
- }
- return groups;
- },
-
- /**
- * Get dates from given event
- *
- * @param {TransformEvent} evt
- * @returns {Object}
- */
- _get_event_dates: function (evt) {
- let date_start = new moment();
- let date_stop = null;
-
- const date_delay = evt[this.date_delay] || false,
- all_day = this.all_day ? evt[this.all_day] : false;
-
- if (all_day) {
- date_start = time.auto_str_to_date(
- evt[this.date_start].split(" ")[0],
- "start"
- );
- if (this.no_period) {
- date_stop = date_start;
} else {
- date_stop = this.date_stop
- ? time.auto_str_to_date(
- evt[this.date_stop].split(" ")[0],
- "stop"
- )
- : null;
- }
- } else {
- date_start = time.auto_str_to_date(evt[this.date_start]);
- date_stop = this.date_stop
- ? time.auto_str_to_date(evt[this.date_stop])
- : null;
- }
-
- if (!date_stop && date_delay) {
- date_stop = date_start.clone().add(date_delay, "hours").toDate();
- }
-
- return [date_start, date_stop];
- },
-
- /**
- * Transform Odoo event object to timeline event object.
- *
- * @param {TransformEvent} evt
- * @private
- * @returns {Object}
- */
- 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;
- }
-
- for (const color of this.colors) {
- if (py.eval(`'${evt[color.field]}' ${color.opt} '${color.value}'`)) {
- this.color = color.color;
+ groups.push({
+ id: group_name[0],
+ content: group_name[1],
+ order: seq,
+ });
+ seq += 1;
}
}
+ }
+ return groups;
+ }
- let content = evt.__name || evt.display_name;
- if (this.arch.children.length) {
- content = this.render_timeline_item(evt);
- }
+ 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});
+ }
+ return groups;
+ }
- 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;
- }
- this.color = null;
- return r;
- },
+ /**
+ * Handle a click within the timeline.
+ *
+ * @param {Object} e
+ * @private
+ */
+ on_timeline_click(e) {
+ if (e.what === "group-label" && e.group !== -1) {
+ this.props.onGroupClick(e);
+ }
+ }
- /**
- * Render timeline item template.
- *
- * @param {Object} evt Record
- * @private
- * @returns {String} Rendered template
- */
- render_timeline_item: function (evt) {
- if (this.qweb.has_template("timeline-item")) {
- return this.qweb.render("timeline-item", {
- record: evt,
- field_utils: field_utils,
- });
- }
+ /**
+ * Handle a double-click within the timeline.
+ *
+ * @param {Object} e
+ * @private
+ */
+ on_timeline_double_click(e) {
+ if (e.what === "item" && e.item !== -1) {
+ this.props.onItemDoubleClick(e);
+ }
+ }
- console.error(
- _t('Template "timeline-item" not present in timeline view definition.')
- );
- },
+ /**
+ * Trigger onUpdate.
+ *
+ * @param {Object} item
+ * @private
+ */
+ on_update(item) {
+ this.props.onUpdate(item);
+ }
- /**
- * Handle a click within the timeline.
- *
- * @param {ClickEvent} e
- * @private
- */
- on_timeline_click: function (e) {
- if (e.what === "group-label" && e.group !== -1) {
- this._trigger(
- e,
- () => {
- // Do nothing
- },
- "onGroupClick"
- );
- }
- },
+ /**
+ * Trigger onMove.
+ *
+ * @param {Object} item
+ * @param {Function} callback
+ * @private
+ */
+ on_move(item, callback) {
+ this.props.onMove(item, callback);
+ }
- /**
- * Handle a double-click within the timeline.
- *
- * @param {ClickEvent} e
- * @private
- */
- on_timeline_double_click: function (e) {
- if (e.what === "item" && e.item !== -1) {
- this._trigger(
- e.item,
- () => {
- // No callback
- },
- "onItemDoubleClick"
- );
- }
- },
+ /**
+ * Trigger onRemove.
+ *
+ * @param {Object} item
+ * @param {Function} callback
+ * @private
+ */
+ on_remove(item, callback) {
+ this.props.onRemove(item, callback);
+ }
- /**
- * Trigger onUpdate.
- *
- * @param {Object} item
- * @param {Function} callback
- * @private
- */
- on_update: function (item, callback) {
- this._trigger(item, callback, "onUpdate");
- },
+ /**
+ * Trigger onAdd.
+ *
+ * @param {Object} item
+ * @param {Function} callback
+ * @private
+ */
+ on_add(item, callback) {
+ this.props.onAdd(item, callback);
+ }
+}
- /**
- * Trigger onMove.
- *
- * @param {Object} item
- * @param {Function} callback
- * @private
- */
- on_move: function (item, callback) {
- this._trigger(item, callback, "onMove");
- },
-
- /**
- * Trigger onRemove.
- *
- * @param {Object} item
- * @param {Function} callback
- * @private
- */
- on_remove: function (item, callback) {
- this._trigger(item, callback, "onRemove");
- },
-
- /**
- * Trigger onAdd.
- *
- * @param {Object} item
- * @param {Function} callback
- * @private
- */
- on_add: function (item, callback) {
- this._trigger(item, callback, "onAdd");
- },
-
- /**
- * Trigger_up encapsulation adds by default the renderer.
- *
- * @param {HTMLElement} item
- * @param {Function} callback
- * @param {String} trigger
- * @private
- */
- _trigger: function (item, callback, trigger) {
- this.trigger_up(trigger, {
- item: item,
- callback: callback,
- renderer: this,
- });
- },
- });
-
- return TimelineRenderer;
-});
+TimelineRenderer.template = "web_timeline.TimelineRenderer";
+TimelineRenderer.props = {
+ model: Object,
+ onAdd: Function,
+ onGroupClick: Function,
+ onItemDoubleClick: Function,
+ onMove: Function,
+ onRemove: Function,
+ onUpdate: Function,
+};
diff --git a/web_timeline/static/src/views/timeline/timeline_renderer.xml b/web_timeline/static/src/views/timeline/timeline_renderer.xml
new file mode 100644
index 000000000..d3793ae4d
--- /dev/null
+++ b/web_timeline/static/src/views/timeline/timeline_renderer.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web_timeline/static/src/views/timeline/timeline_view.esm.js b/web_timeline/static/src/views/timeline/timeline_view.esm.js
index 3971db4f7..d5f1dde43 100644
--- a/web_timeline/static/src/views/timeline/timeline_view.esm.js
+++ b/web_timeline/static/src/views/timeline/timeline_view.esm.js
@@ -1,174 +1,48 @@
-/* global py */
+/** @odoo-module **/
/* Odoo web_timeline
* Copyright 2015 ACSONE SA/NV
* Copyright 2016 Pedro M. Baeza
* Copyright 2023 Onestein - Anjeel Haria
+ * Copyright 2024 Tecnativa - Carlos López
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */
-odoo.define("web_timeline.TimelineView", function (require) {
- "use strict";
+import {TimelineArchParser} from "./timeline_arch_parser.esm";
+import {TimelineController} from "./timeline_controller.esm";
+import {TimelineModel} from "./timeline_model.esm";
+import {TimelineRenderer} from "./timeline_renderer.esm";
+import {_lt} from "@web/core/l10n/translation";
+import {registry} from "@web/core/registry";
- const core = require("web.core");
- const utils = require("web.utils");
- const view_registry = require("web.view_registry");
- const AbstractView = require("web.AbstractView");
- const TimelineRenderer = require("web_timeline.TimelineRenderer");
- const TimelineController = require("web_timeline.TimelineController");
- const TimelineModel = require("web_timeline.TimelineModel");
+const viewRegistry = registry.category("views");
- const _lt = core._lt;
+export const TimelineView = {
+ display_name: _lt("Timeline"),
+ icon: "fa fa-tasks",
+ multiRecord: true,
+ ArchParser: TimelineArchParser,
+ Controller: TimelineController,
+ Renderer: TimelineRenderer,
+ Model: TimelineModel,
+ jsLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.js"],
+ cssLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.css"],
+ type: "timeline",
- function isNullOrUndef(value) {
- return _.isUndefined(value) || _.isNull(value);
- }
+ props: (genericProps, view) => {
+ const {arch, fields, resModel} = genericProps;
+ const parser = new view.ArchParser();
+ const archInfo = parser.parse(arch, fields);
+ const modelParams = {
+ ...archInfo,
+ resModel: resModel,
+ fields: fields,
+ };
- function toBoolDefaultTrue(value) {
- return isNullOrUndef(value) ? true : utils.toBoolElse(value, true);
- }
-
- var TimelineView = AbstractView.extend({
- display_name: _lt("Timeline"),
- icon: "fa fa-tasks",
- jsLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.js"],
- cssLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.css"],
- config: _.extend({}, AbstractView.prototype.config, {
- Model: TimelineModel,
- Controller: TimelineController,
- Renderer: TimelineRenderer,
- }),
- viewType: "timeline",
-
- /**
- * @override
- */
- init: function (viewInfo, params) {
- this._super.apply(this, arguments);
- this.modelName = this.controllerParams.modelName;
-
- const action = params.action;
- this.arch = this.rendererParams.arch;
- const attrs = this.arch.attrs;
- const date_start = attrs.date_start;
- const date_stop = attrs.date_stop;
- const date_delay = attrs.date_delay;
- const dependency_arrow = attrs.dependency_arrow;
-
- const fields = viewInfo.fields;
- let fieldNames = fields.display_name ? ["display_name"] : [];
- const fieldsToGather = [
- "date_start",
- "date_stop",
- "default_group_by",
- "progress",
- "date_delay",
- attrs.default_group_by,
- ];
-
- for (const field of fieldsToGather) {
- if (attrs[field]) {
- fieldNames.push(attrs[field]);
- }
- }
-
- const archFieldNames = _.map(
- _.filter(this.arch.children, (item) => item.tag === "field"),
- (item) => item.attrs.name
- );
- fieldNames = _.union(fieldNames, archFieldNames);
-
- const colors = this.parse_colors();
- for (const color of colors) {
- if (!fieldNames.includes(color.field)) {
- fieldNames.push(color.field);
- }
- }
-
- if (dependency_arrow) {
- fieldNames.push(dependency_arrow);
- }
-
- const mode = attrs.mode || attrs.default_window || "fit";
- const min_height = attrs.min_height || 300;
-
- if (!isNullOrUndef(attrs.quick_create_instance)) {
- this.quick_create_instance = "instance." + attrs.quick_create_instance;
- }
- let open_popup_action = false;
- if (
- !isNullOrUndef(attrs.event_open_popup) &&
- utils.toBoolElse(attrs.event_open_popup, true)
- ) {
- open_popup_action = attrs.event_open_popup;
- }
- this.rendererParams.mode = mode;
- this.rendererParams.model = this.modelName;
- this.rendererParams.view = this;
- this.rendererParams.options = this._preapre_vis_timeline_options(attrs);
- this.rendererParams.can_create = toBoolDefaultTrue(attrs.create);
- this.rendererParams.can_update = toBoolDefaultTrue(attrs.edit);
- this.rendererParams.can_delete = toBoolDefaultTrue(attrs.delete);
- this.rendererParams.date_start = date_start;
- this.rendererParams.date_stop = date_stop;
- this.rendererParams.date_delay = date_delay;
- this.rendererParams.colors = colors;
- this.rendererParams.fieldNames = fieldNames;
- this.rendererParams.default_group_by = attrs.default_group_by;
- this.rendererParams.min_height = min_height;
- this.rendererParams.dependency_arrow = dependency_arrow;
- this.rendererParams.fields = fields;
- this.loadParams.modelName = this.modelName;
- this.loadParams.fieldNames = fieldNames;
- this.loadParams.default_group_by = attrs.default_group_by;
- this.controllerParams.open_popup_action = open_popup_action;
- this.controllerParams.date_start = date_start;
- this.controllerParams.date_stop = date_stop;
- this.controllerParams.date_delay = date_delay;
- this.controllerParams.actionContext = action.context;
- this.withSearchPanel = false;
- },
-
- _preapre_vis_timeline_options: function (attrs) {
- return {
- groupOrder: "order",
- orientation: {axis: "both", item: "top"},
- selectable: true,
- multiselect: true,
- showCurrentTime: true,
- stack: toBoolDefaultTrue(attrs.stack),
- margin: attrs.margin ? JSON.parse(attrs.margin) : {item: 2},
- zoomKey: attrs.zoomKey || "ctrlKey",
- };
- },
-
- /**
- * Parse the colors attribute.
- *
- * @private
- * @returns {Array}
- */
- parse_colors: function () {
- if (this.arch.attrs.colors) {
- return _(this.arch.attrs.colors.split(";"))
- .chain()
- .compact()
- .map((color_pair) => {
- const pair = color_pair.split(":");
- const color = pair[0];
- const expr = pair[1];
- const temp = py.parse(py.tokenize(expr));
- return {
- color: color,
- field: temp.expressions[0].value,
- opt: temp.operators[0],
- value: temp.expressions[1].value,
- };
- })
- .value();
- }
- return [];
- },
- });
-
- view_registry.add("timeline", TimelineView);
- return TimelineView;
-});
+ return {
+ ...genericProps,
+ modelParams,
+ Model: view.Model,
+ Renderer: view.Renderer,
+ };
+ },
+};
+viewRegistry.add("timeline", TimelineView);
diff --git a/web_timeline/static/src/views/timeline/timeline_view.xml b/web_timeline/static/src/views/timeline/timeline_view.xml
deleted file mode 100644
index c6aab8dc5..000000000
--- a/web_timeline/static/src/views/timeline/timeline_view.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
diff --git a/web_timeline/static/tests/helpers.esm.js b/web_timeline/static/tests/helpers.esm.js
new file mode 100644
index 000000000..e8cd9ff80
--- /dev/null
+++ b/web_timeline/static/tests/helpers.esm.js
@@ -0,0 +1,8 @@
+/** @odoo-module **/
+
+export const FAKE_ORDER_FIELDS = {
+ display_name: {string: "Display Name", type: "char"},
+ date_start: {string: "Date start", type: "date"},
+ date_end: {string: "Date end", type: "date"},
+ partner_id: {string: "Partner", type: "many2one", relation: "partner"},
+};
diff --git a/web_timeline/static/tests/web_timeline_arch_parser_tests.esm.js b/web_timeline/static/tests/web_timeline_arch_parser_tests.esm.js
new file mode 100644
index 000000000..c0d4b18aa
--- /dev/null
+++ b/web_timeline/static/tests/web_timeline_arch_parser_tests.esm.js
@@ -0,0 +1,141 @@
+/** @odoo-module **/
+import {
+ TimelineArchParser,
+ TimelineParseArchError,
+} from "@web_timeline/views/timeline/timeline_arch_parser.esm";
+import {FAKE_ORDER_FIELDS} from "./helpers.esm";
+import {parseXML} from "@web/core/utils/xml";
+
+function parseArch(arch) {
+ const parser = new TimelineArchParser();
+ const xmlDoc = parseXML(arch);
+ return parser.parse(xmlDoc, FAKE_ORDER_FIELDS);
+}
+
+function check(assert, paramName, paramValue, expectedName, expectedValue) {
+ const arch = ` `;
+ const data = parseArch(arch);
+ assert.strictEqual(data[expectedName], expectedValue);
+}
+// eslint-disable-next-line no-undef
+QUnit.module("TimelineView - ArchParser");
+// eslint-disable-next-line no-undef
+QUnit.test("throw if date_start is not set", (assert) => {
+ assert.throws(
+ () => parseArch(` `),
+ TimelineParseArchError
+ );
+});
+// eslint-disable-next-line no-undef
+QUnit.test("throw if default_group_by is not set", (assert) => {
+ assert.throws(
+ () => parseArch(` `),
+ TimelineParseArchError
+ );
+});
+// eslint-disable-next-line no-undef
+QUnit.test("hasEditDialog", (assert) => {
+ check(assert, "event_open_popup", "", "open_popup_action", false);
+ check(assert, "event_open_popup", "true", "open_popup_action", true);
+ check(assert, "event_open_popup", "True", "open_popup_action", true);
+ check(assert, "event_open_popup", "1", "open_popup_action", true);
+ check(assert, "event_open_popup", "false", "open_popup_action", false);
+ check(assert, "event_open_popup", "False", "open_popup_action", false);
+ check(assert, "event_open_popup", "0", "open_popup_action", false);
+});
+// eslint-disable-next-line no-undef
+QUnit.test("create", (assert) => {
+ check(assert, "create", "", "canCreate", true);
+ check(assert, "create", "true", "canCreate", true);
+ check(assert, "create", "True", "canCreate", true);
+ check(assert, "create", "1", "canCreate", true);
+ check(assert, "create", "false", "canCreate", false);
+ check(assert, "create", "False", "canCreate", false);
+ check(assert, "create", "0", "canCreate", false);
+ check(assert, "create", "12", "canCreate", true);
+});
+// eslint-disable-next-line no-undef
+QUnit.test("edit", (assert) => {
+ check(assert, "edit", "", "canUpdate", true);
+ check(assert, "edit", "true", "canUpdate", true);
+ check(assert, "edit", "True", "canUpdate", true);
+ check(assert, "edit", "1", "canUpdate", true);
+ check(assert, "edit", "false", "canUpdate", false);
+ check(assert, "edit", "False", "canUpdate", false);
+ check(assert, "edit", "0", "canUpdate", false);
+ check(assert, "edit", "12", "canUpdate", true);
+});
+
+// eslint-disable-next-line no-undef
+QUnit.test("delete", (assert) => {
+ check(assert, "delete", "", "canDelete", true);
+ check(assert, "delete", "true", "canDelete", true);
+ check(assert, "delete", "True", "canDelete", true);
+ check(assert, "delete", "1", "canDelete", true);
+ check(assert, "delete", "false", "canDelete", false);
+ check(assert, "delete", "False", "canDelete", false);
+ check(assert, "delete", "0", "canDelete", false);
+ check(assert, "delete", "12", "canDelete", true);
+});
+// eslint-disable-next-line no-undef
+QUnit.test("mode", (assert) => {
+ check(assert, "mode", "day", "mode", "day");
+ check(assert, "mode", "week", "mode", "week");
+ check(assert, "mode", "month", "mode", "month");
+ assert.throws(() => {
+ parseArch(
+ ` `
+ );
+ }, TimelineParseArchError);
+
+ assert.throws(() => {
+ parseArch(
+ ` `
+ );
+ }, TimelineParseArchError);
+});
+// eslint-disable-next-line no-undef
+QUnit.test("colors", (assert) => {
+ const archInfo = parseArch(`
+
+ `);
+ assert.strictEqual(archInfo.colors.length, 2, "colors should be 2");
+ assert.strictEqual(archInfo.colors[0].field, "state", "field should be state");
+ assert.strictEqual(archInfo.colors[0].color, "gray", "color should be gray");
+ assert.strictEqual(
+ archInfo.colors[0].ast.left.value,
+ "state",
+ "ast left value should be state"
+ );
+ assert.strictEqual(archInfo.colors[0].ast.op, "==", "ast op value should be '=='");
+ assert.strictEqual(
+ archInfo.colors[0].ast.right.value,
+ "cancel",
+ "ast right value should be cancel"
+ );
+ assert.ok(
+ archInfo.fieldNames.includes("state"),
+ "fieldNames should include field state"
+ );
+});
+// eslint-disable-next-line no-undef
+QUnit.test("templates", (assert) => {
+ const archInfo = parseArch(`
+
+
+
+
+
+
+
+
+ `);
+ assert.ok(
+ archInfo.templateDocs.hasOwnProperty("timeline-item"),
+ "template name should be timeline-item"
+ );
+ assert.ok(
+ archInfo.fieldNames.includes("other_field"),
+ "fieldNames should include field other_field"
+ );
+});
diff --git a/web_timeline/static/tests/web_timeline_view_tests.esm.js b/web_timeline/static/tests/web_timeline_view_tests.esm.js
new file mode 100644
index 000000000..fbd3291ce
--- /dev/null
+++ b/web_timeline/static/tests/web_timeline_view_tests.esm.js
@@ -0,0 +1,194 @@
+/** @odoo-module **/
+import {click, getFixture} from "@web/../tests/helpers/utils";
+import {makeView, setupViewRegistries} from "@web/../tests/views/helpers";
+import {FAKE_ORDER_FIELDS} from "./helpers.esm";
+import {loadBundle} from "@web/core/assets";
+
+let serverData = {};
+let target = null;
+// eslint-disable-next-line no-undef
+QUnit.module("Views", (hooks) => {
+ loadBundle("web_timeline.vis-timeline_lib");
+ hooks.beforeEach(async () => {
+ serverData = {
+ models: {
+ partner: {
+ fields: {
+ name: {string: "Name", type: "char"},
+ },
+ records: [
+ {id: 1, name: "Partner 1"},
+ {id: 2, name: "Partner 2"},
+ {id: 3, name: "Partner 3"},
+ ],
+ },
+ order: {
+ fields: FAKE_ORDER_FIELDS,
+ records: [
+ {
+ id: 1,
+ display_name: "Record 1",
+ date_start: "2024-01-01",
+ date_end: "2024-01-02",
+ partner_id: 1,
+ },
+ {
+ id: 2,
+ display_name: "Record 2",
+ date_start: "2024-01-03",
+ date_end: "2024-02-05",
+ partner_id: 1,
+ },
+ {
+ id: 3,
+ display_name: "Record 3",
+ date_start: "2024-01-10",
+ date_end: "2024-01-15",
+ partner_id: 2,
+ },
+ {
+ id: 4,
+ display_name: "Record 4",
+ date_start: "2024-01-15",
+ date_end: "2024-02-01",
+ partner_id: 3,
+ },
+ ],
+ methods: {
+ check_access_rights() {
+ return Promise.resolve(true);
+ },
+ },
+ },
+ },
+ };
+ setupViewRegistries();
+ target = getFixture();
+ });
+ // eslint-disable-next-line no-undef
+ QUnit.module("TimelineView - View");
+ // eslint-disable-next-line no-undef
+ QUnit.test("Test basic timeline view", async (assert) => {
+ await makeView({
+ type: "timeline",
+ resModel: "order",
+ serverData,
+ arch: ' ',
+ });
+ assert.containsOnce(target, ".oe_timeline_view");
+ });
+ // eslint-disable-next-line no-undef
+ QUnit.test("click today slot", async (assert) => {
+ await makeView({
+ type: "timeline",
+ resModel: "order",
+ serverData,
+ arch: ' ',
+ });
+ const $today = target.querySelector(".oe_timeline_button_today");
+ const $day = target.querySelector(".oe_timeline_button_scale_day");
+ const $week = target.querySelector(".oe_timeline_button_scale_week");
+ const $month = target.querySelector(".oe_timeline_button_scale_month");
+ const $year = target.querySelector(".oe_timeline_button_scale_year");
+ await click($today);
+ assert.hasClass(
+ $today,
+ "btn-primary",
+ "today should have classnames btn-primary"
+ );
+ assert.doesNotHaveClass(
+ $day,
+ "btn-primary",
+ "day should no have classnames btn-primary"
+ );
+ assert.doesNotHaveClass(
+ $week,
+ "btn-primary",
+ "week should no have classnames btn-primary"
+ );
+ assert.doesNotHaveClass(
+ $month,
+ "btn-primary",
+ "month should no have classnames btn-primary"
+ );
+ assert.doesNotHaveClass(
+ $year,
+ "btn-primary",
+ "year should no have classnames btn-primary"
+ );
+ });
+ // eslint-disable-next-line no-undef
+ QUnit.test("click month slot", async (assert) => {
+ await makeView({
+ type: "timeline",
+ resModel: "order",
+ serverData,
+ arch: ' ',
+ });
+ const $today = target.querySelector(".oe_timeline_button_today");
+ const $day = target.querySelector(".oe_timeline_button_scale_day");
+ const $week = target.querySelector(".oe_timeline_button_scale_week");
+ const $month = target.querySelector(".oe_timeline_button_scale_month");
+ const $year = target.querySelector(".oe_timeline_button_scale_year");
+ await click($month);
+ assert.hasClass(
+ $month,
+ "btn-primary",
+ "month should have classnames btn-primary"
+ );
+ assert.doesNotHaveClass(
+ $today,
+ "btn-primary",
+ "today should no have classnames btn-primary"
+ );
+ assert.doesNotHaveClass(
+ $day,
+ "btn-primary",
+ "day should no have classnames btn-primary"
+ );
+ assert.doesNotHaveClass(
+ $week,
+ "btn-primary",
+ "week should no have classnames btn-primary"
+ );
+ assert.doesNotHaveClass(
+ $year,
+ "btn-primary",
+ "year should no have classnames btn-primary"
+ );
+ });
+ // eslint-disable-next-line no-undef
+ QUnit.test("Check button delete", async (assert) => {
+ await makeView({
+ type: "timeline",
+ resModel: "order",
+ serverData,
+ arch: ' ',
+ });
+ const $elements = [...target.querySelectorAll(".vis-item-content")];
+ const $item_contents = $elements.filter((el) =>
+ el.textContent.includes("Record 2")
+ );
+ assert.strictEqual($item_contents.length, 1, "items should be 1");
+ const $item_content = $item_contents[0];
+ await click($item_content);
+ assert.containsOnce($item_content.parentElement, ".vis-delete");
+ });
+ // eslint-disable-next-line no-undef
+ QUnit.test("Check button delete disabled", async (assert) => {
+ await makeView({
+ type: "timeline",
+ resModel: "order",
+ serverData,
+ arch: ' ',
+ });
+ const $elements = [...target.querySelectorAll(".vis-item-content")];
+ const $item_contents = $elements.filter((el) =>
+ el.textContent.includes("Record 2")
+ );
+ assert.strictEqual($item_contents.length, 1, "items should be 1");
+ const $item_content = $item_contents[0];
+ await click($item_content);
+ assert.containsNone($item_content.parentElement, ".vis-delete");
+ });
+});
diff --git a/web_timeline/tests/__init__.py b/web_timeline/tests/__init__.py
new file mode 100644
index 000000000..4e45262a1
--- /dev/null
+++ b/web_timeline/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_web_timeline
diff --git a/web_timeline/tests/test_web_timeline.py b/web_timeline/tests/test_web_timeline.py
new file mode 100644
index 000000000..f69a5d19a
--- /dev/null
+++ b/web_timeline/tests/test_web_timeline.py
@@ -0,0 +1,23 @@
+# Copyright 2024 Tecnativa - Carlos Lopez
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo.tests import tagged
+from odoo.tests.common import HttpCase
+
+
+@tagged("post_install", "-at_install")
+class TestWebTimeline(HttpCase):
+ def test_timeline_arch(self):
+ self.browser_js(
+ "/web/tests?filter=TimelineView - ArchParser",
+ "",
+ login="admin",
+ timeout=1800,
+ )
+
+ def test_timeline_view(self):
+ self.browser_js(
+ "/web/tests?filter=TimelineView - View",
+ "",
+ login="admin",
+ timeout=1800,
+ )