[MIG] web_timeline: Migration to 17.0

- Convert Moment.js to Luxon.
- Replace Underscore.js with native JavaScript code.
- Migrate legacy views to the new system and add an architecture parser to separate logic.
- added basic test
pull/3136/head
Carlos Lopez 2024-10-06 11:10:16 -05:00 committed by JasminSForgeFlow
parent 614a3b133b
commit 293fead8d9
23 changed files with 1652 additions and 1346 deletions

View File

@ -135,8 +135,10 @@ rendering:
- ``record``: to access the fields values selected in the timeline - ``record``: to access the fields values selected in the timeline
definition. definition.
- ``field_utils``: used to format and parse values (see available - ``formatters``: used to format values (see available functions in
functions in ``web.field_utils``). ``@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 You also need to declare the view in an action window of the involved
model. model.
@ -159,15 +161,15 @@ More evolved example, from ``project_timeline``:
string="Tasks" string="Tasks"
default_group_by="project_id" default_group_by="project_id"
event_open_popup="true" 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" dependency_arrow="depend_on_ids"
> >
<field name="user_ids" /> <field name="user_ids" />
<field name="planned_hours" /> <field name="allocated_hours" />
<templates> <templates>
<t t-name="timeline-item"> <t t-name="timeline-item">
<div class="o_project_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 <img
t-if="record.user_ids" t-if="record.user_ids"
t-attf-src="/web/image/res.users/#{user}/image_128/16x16" t-attf-src="/web/image/res.users/#{user}/image_128/16x16"
@ -182,12 +184,12 @@ More evolved example, from ``project_timeline``:
<t t-esc="record.display_name" /> <t t-esc="record.display_name" />
</span> </span>
<small <small
name="planned_hours" name="allocated_hours"
class="text-info ml4" class="text-info ml4"
t-if="record.planned_hours" t-if="record.allocated_hours"
> >
<t <t
t-esc="field_utils.format.float_time(record.planned_hours)" t-out="formatters.get('float_time')(record.allocated_hours)"
/> />
</small> </small>
</div> </div>
@ -293,6 +295,7 @@ Contributors
- Pedro M. Baeza - Pedro M. Baeza
- Alexandre Díaz - Alexandre Díaz
- César A. Sánchez - César A. Sánchez
- Carlos López
- `Onestein <https://www.onestein.nl>`__: - `Onestein <https://www.onestein.nl>`__:

View File

@ -1,10 +1,11 @@
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>) # Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
# Copyright 2024 Tecnativa - Carlos Lopez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{ {
"name": "Web timeline", "name": "Web timeline",
"summary": "Interactive visualization chart to show events in time", "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", "development_status": "Production/Stable",
"author": "ACSONE SA/NV, " "author": "ACSONE SA/NV, "
"Tecnativa, " "Tecnativa, "
@ -25,13 +26,24 @@
"web.assets_backend": [ "web.assets_backend": [
"web_timeline/static/src/views/timeline/timeline_view.scss", "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_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_view.esm.js",
"web_timeline/static/src/views/timeline/timeline_renderer.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.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_model.esm.js",
"web_timeline/static/src/views/timeline/timeline_canvas.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_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",
], ],
}, },
} }

View File

@ -2,3 +2,4 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from . import ir_ui_view from . import ir_ui_view
from . import ir_action

View File

@ -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"}
)

View File

@ -1,4 +1,5 @@
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>) # Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
# Copyright 2024 Tecnativa - Carlos Lopez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import fields, models from odoo import fields, models
@ -10,3 +11,6 @@ class IrUIView(models.Model):
_inherit = "ir.ui.view" _inherit = "ir.ui.view"
type = fields.Selection(selection_add=[TIMELINE_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)

View File

@ -20,10 +20,9 @@ render the timeline items. You have to name the template
'timeline-item'. These are the variables available in template 'timeline-item'. These are the variables available in template
rendering: rendering:
- `record`: to access the fields values selected in the timeline - `record`: to access the fields values selected in the timeline definition.
definition. - `formatters`: used to format values (see available functions in `@web/views/fields/formatters`).
- `field_utils`: used to format and parse values (see available - `parsers`: used to parse values (see available functions in `@web/views/fields/parsers`).
functions in `web.field_utils`).
You also need to declare the view in an action window of the involved You also need to declare the view in an action window of the involved
model. model.
@ -45,15 +44,15 @@ More evolved example, from `project_timeline`:
string="Tasks" string="Tasks"
default_group_by="project_id" default_group_by="project_id"
event_open_popup="true" 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" dependency_arrow="depend_on_ids"
> >
<field name="user_ids" /> <field name="user_ids" />
<field name="planned_hours" /> <field name="allocated_hours" />
<templates> <templates>
<t t-name="timeline-item"> <t t-name="timeline-item">
<div class="o_project_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 <img
t-if="record.user_ids" t-if="record.user_ids"
t-attf-src="/web/image/res.users/#{user}/image_128/16x16" t-attf-src="/web/image/res.users/#{user}/image_128/16x16"
@ -68,12 +67,12 @@ More evolved example, from `project_timeline`:
<t t-esc="record.display_name" /> <t t-esc="record.display_name" />
</span> </span>
<small <small
name="planned_hours" name="allocated_hours"
class="text-info ml4" class="text-info ml4"
t-if="record.planned_hours" t-if="record.allocated_hours"
> >
<t <t
t-esc="field_utils.format.float_time(record.planned_hours)" t-out="formatters.get('float_time')(record.allocated_hours)"
/> />
</small> </small>
</div> </div>

View File

@ -9,6 +9,7 @@
- Pedro M. Baeza - Pedro M. Baeza
- Alexandre Díaz - Alexandre Díaz
- César A. Sánchez - César A. Sánchez
- Carlos López
- [Onestein](https://www.onestein.nl): - [Onestein](https://www.onestein.nl):
- Dennis Sluijk \<<d.sluijk@onestein.nl>\> - Dennis Sluijk \<<d.sluijk@onestein.nl>\>
- Anjeel Haria - Anjeel Haria

View File

@ -516,8 +516,10 @@ rendering:</p>
<ul class="simple"> <ul class="simple">
<li><tt class="docutils literal">record</tt>: to access the fields values selected in the timeline <li><tt class="docutils literal">record</tt>: to access the fields values selected in the timeline
definition.</li> definition.</li>
<li><tt class="docutils literal">field_utils</tt>: used to format and parse values (see available <li><tt class="docutils literal">formatters</tt>: used to format values (see available functions in
functions in <tt class="docutils literal">web.field_utils</tt>).</li> <tt class="docutils literal">&#64;web/views/fields/formatters</tt>).</li>
<li><tt class="docutils literal">parsers</tt>: used to parse values (see available functions in
<tt class="docutils literal">&#64;web/views/fields/parsers</tt>).</li>
</ul> </ul>
<p>You also need to declare the view in an action window of the involved <p>You also need to declare the view in an action window of the involved
model.</p> model.</p>
@ -536,15 +538,15 @@ view example added onto cron tasks.</p>
</span><span class="na">string=</span><span class="s">&quot;Tasks&quot;</span><span class="w"> </span><span class="na">string=</span><span class="s">&quot;Tasks&quot;</span><span class="w">
</span><span class="na">default_group_by=</span><span class="s">&quot;project_id&quot;</span><span class="w"> </span><span class="na">default_group_by=</span><span class="s">&quot;project_id&quot;</span><span class="w">
</span><span class="na">event_open_popup=</span><span class="s">&quot;true&quot;</span><span class="w"> </span><span class="na">event_open_popup=</span><span class="s">&quot;true&quot;</span><span class="w">
</span><span class="na">colors=</span><span class="s">&quot;white: user_ids == []; #2ecb71: kanban_state == 'done'; #ec7063: kanban_state == 'blocked'&quot;</span><span class="w"> </span><span class="na">colors=</span><span class="s">&quot;white: user_ids == []; #2ecb71: state == '1_done'; #ec7063: state == '1_canceled'&quot;</span><span class="w">
</span><span class="na">dependency_arrow=</span><span class="s">&quot;depend_on_ids&quot;</span><span class="w"> </span><span class="na">dependency_arrow=</span><span class="s">&quot;depend_on_ids&quot;</span><span class="w">
</span><span class="nt">&gt;</span><span class="w"> </span><span class="nt">&gt;</span><span class="w">
</span><span class="nt">&lt;field</span><span class="w"> </span><span class="na">name=</span><span class="s">&quot;user_ids&quot;</span><span class="w"> </span><span class="nt">/&gt;</span><span class="w"> </span><span class="nt">&lt;field</span><span class="w"> </span><span class="na">name=</span><span class="s">&quot;user_ids&quot;</span><span class="w"> </span><span class="nt">/&gt;</span><span class="w">
</span><span class="nt">&lt;field</span><span class="w"> </span><span class="na">name=</span><span class="s">&quot;planned_hours&quot;</span><span class="w"> </span><span class="nt">/&gt;</span><span class="w"> </span><span class="nt">&lt;field</span><span class="w"> </span><span class="na">name=</span><span class="s">&quot;allocated_hours&quot;</span><span class="w"> </span><span class="nt">/&gt;</span><span class="w">
</span><span class="nt">&lt;templates&gt;</span><span class="w"> </span><span class="nt">&lt;templates&gt;</span><span class="w">
</span><span class="nt">&lt;t</span><span class="w"> </span><span class="na">t-name=</span><span class="s">&quot;timeline-item&quot;</span><span class="nt">&gt;</span><span class="w"> </span><span class="nt">&lt;t</span><span class="w"> </span><span class="na">t-name=</span><span class="s">&quot;timeline-item&quot;</span><span class="nt">&gt;</span><span class="w">
</span><span class="nt">&lt;div</span><span class="w"> </span><span class="na">class=</span><span class="s">&quot;o_project_timeline_item&quot;</span><span class="nt">&gt;</span><span class="w"> </span><span class="nt">&lt;div</span><span class="w"> </span><span class="na">class=</span><span class="s">&quot;o_project_timeline_item&quot;</span><span class="nt">&gt;</span><span class="w">
</span><span class="nt">&lt;t</span><span class="w"> </span><span class="na">t-foreach=</span><span class="s">&quot;record.user_ids&quot;</span><span class="w"> </span><span class="na">t-as=</span><span class="s">&quot;user&quot;</span><span class="nt">&gt;</span><span class="w"> </span><span class="nt">&lt;t</span><span class="w"> </span><span class="na">t-foreach=</span><span class="s">&quot;record.user_ids&quot;</span><span class="w"> </span><span class="na">t-as=</span><span class="s">&quot;user&quot;</span><span class="w"> </span><span class="na">t-key=</span><span class="s">&quot;user.id&quot;</span><span class="nt">&gt;</span><span class="w">
</span><span class="nt">&lt;img</span><span class="w"> </span><span class="nt">&lt;img</span><span class="w">
</span><span class="na">t-if=</span><span class="s">&quot;record.user_ids&quot;</span><span class="w"> </span><span class="na">t-if=</span><span class="s">&quot;record.user_ids&quot;</span><span class="w">
</span><span class="na">t-attf-src=</span><span class="s">&quot;/web/image/res.users/#{user}/image_128/16x16&quot;</span><span class="w"> </span><span class="na">t-attf-src=</span><span class="s">&quot;/web/image/res.users/#{user}/image_128/16x16&quot;</span><span class="w">
@ -559,12 +561,12 @@ view example added onto cron tasks.</p>
</span><span class="nt">&lt;t</span><span class="w"> </span><span class="na">t-esc=</span><span class="s">&quot;record.display_name&quot;</span><span class="w"> </span><span class="nt">/&gt;</span><span class="w"> </span><span class="nt">&lt;t</span><span class="w"> </span><span class="na">t-esc=</span><span class="s">&quot;record.display_name&quot;</span><span class="w"> </span><span class="nt">/&gt;</span><span class="w">
</span><span class="nt">&lt;/span&gt;</span><span class="w"> </span><span class="nt">&lt;/span&gt;</span><span class="w">
</span><span class="nt">&lt;small</span><span class="w"> </span><span class="nt">&lt;small</span><span class="w">
</span><span class="na">name=</span><span class="s">&quot;planned_hours&quot;</span><span class="w"> </span><span class="na">name=</span><span class="s">&quot;allocated_hours&quot;</span><span class="w">
</span><span class="na">class=</span><span class="s">&quot;text-info ml4&quot;</span><span class="w"> </span><span class="na">class=</span><span class="s">&quot;text-info ml4&quot;</span><span class="w">
</span><span class="na">t-if=</span><span class="s">&quot;record.planned_hours&quot;</span><span class="w"> </span><span class="na">t-if=</span><span class="s">&quot;record.allocated_hours&quot;</span><span class="w">
</span><span class="nt">&gt;</span><span class="w"> </span><span class="nt">&gt;</span><span class="w">
</span><span class="nt">&lt;t</span><span class="w"> </span><span class="nt">&lt;t</span><span class="w">
</span><span class="na">t-esc=</span><span class="s">&quot;field_utils.format.float_time(record.planned_hours)&quot;</span><span class="w"> </span><span class="na">t-out=</span><span class="s">&quot;formatters.get('float_time')(record.allocated_hours)&quot;</span><span class="w">
</span><span class="nt">/&gt;</span><span class="w"> </span><span class="nt">/&gt;</span><span class="w">
</span><span class="nt">&lt;/small&gt;</span><span class="w"> </span><span class="nt">&lt;/small&gt;</span><span class="w">
</span><span class="nt">&lt;/div&gt;</span><span class="w"> </span><span class="nt">&lt;/div&gt;</span><span class="w">
@ -663,6 +665,7 @@ If you spotted it first, help us to smash it by providing a detailed and welcome
<li>Pedro M. Baeza</li> <li>Pedro M. Baeza</li>
<li>Alexandre Díaz</li> <li>Alexandre Díaz</li>
<li>César A. Sánchez</li> <li>César A. Sánchez</li>
<li>Carlos López</li>
</ul> </ul>
</li> </li>
<li><a class="reference external" href="https://www.onestein.nl">Onestein</a>:<ul> <li><a class="reference external" href="https://www.onestein.nl">Onestein</a>:<ul>

View File

@ -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 [];
}
}

View File

@ -1,22 +1,22 @@
/** @odoo-module **/
/* Copyright 2018 Onestein /* Copyright 2018 Onestein
Copyright 2024 Tecnativa - Carlos López
* License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */ * 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. * Used to draw stuff on upon the timeline view.
*/ */
const TimelineCanvas = Widget.extend({
template: "TimelineView.Canvas",
export class TimelineCanvas {
constructor(canvas_ref) {
this.canvas_ref = canvas_ref;
}
/** /**
* Clears all drawings (svg elements) from the canvas. * Clears all drawings (svg elements) from the canvas.
*/ */
clear: function () { clear() {
this.$(" > :not(defs)").remove(); $(this.canvas_ref).find(" > :not(defs)").remove();
}, }
/** /**
* Gets the path from one point to another. * Gets the path from one point to another.
@ -27,7 +27,7 @@ odoo.define("web_timeline.TimelineCanvas", function (require) {
* @param {Number} breakAt The space between the line turns * @param {Number} breakAt The space between the line turns
* @returns {Array} Each item represents a coordinate * @returns {Array} Each item represents a coordinate
*/ */
get_polyline_points: function (rectFrom, rectTo, widthMarker, breakAt) { get_polyline_points(rectFrom, rectTo, widthMarker, breakAt) {
let fromX = 0, let fromX = 0,
toX = 0; toX = 0;
if (rectFrom.x < rectTo.x + rectTo.w) { if (rectFrom.x < rectTo.x + rectTo.w) {
@ -80,7 +80,7 @@ odoo.define("web_timeline.TimelineCanvas", function (require) {
points.push([toX, toY]); points.push([toX, toY]);
return points; return points;
}, }
/** /**
* Draws an arrow. * Draws an arrow.
@ -91,9 +91,9 @@ odoo.define("web_timeline.TimelineCanvas", function (require) {
* @param {Number} width Width of the line * @param {Number} width Width of the line
* @returns {HTMLElement} The created SVG polyline * @returns {HTMLElement} The created SVG polyline
*/ */
draw_arrow: function (from, to, color, width) { draw_arrow(from, to, color, width) {
return this.draw_line(from, to, color, width, "#arrowhead", 10, 12); return this.draw_line(from, to, color, width, "#arrowhead", 10, 12);
}, }
/** /**
* Draws a line. * Draws a line.
@ -107,15 +107,7 @@ odoo.define("web_timeline.TimelineCanvas", function (require) {
* @param {Number} breakLineAt The space between the line turns * @param {Number} breakLineAt The space between the line turns
* @returns {HTMLElement} The created SVG polyline * @returns {HTMLElement} The created SVG polyline
*/ */
draw_line: function ( draw_line(from, to, color, width, markerStart, widthMarker, breakLineAt) {
from,
to,
color,
width,
markerStart,
widthMarker,
breakLineAt
) {
const $from = $(from); const $from = $(from);
const childPosFrom = $from.offset(); const childPosFrom = $from.offset();
const parentPosFrom = $from.closest(".vis-center").offset(); const parentPosFrom = $from.closest(".vis-center").offset();
@ -140,21 +132,15 @@ odoo.define("web_timeline.TimelineCanvas", function (require) {
widthMarker, widthMarker,
breakLineAt breakLineAt
); );
const line = document.createElementNS( const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
"http://www.w3.org/2000/svg", line.setAttribute("points", points.flat().join(","));
"polyline"
);
line.setAttribute("points", _.flatten(points).join(","));
line.setAttribute("stroke", color || "#000"); line.setAttribute("stroke", color || "#000");
line.setAttribute("stroke-width", width || 1); line.setAttribute("stroke-width", width || 1);
line.setAttribute("fill", "none"); line.setAttribute("fill", "none");
if (markerStart) { if (markerStart) {
line.setAttribute("marker-start", "url(" + markerStart + ")"); line.setAttribute("marker-start", "url(" + markerStart + ")");
} }
this.$el.append(line); this.canvas_ref.append(line);
return line; return line;
}, }
}); }
return TimelineCanvas;
});

View File

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<template>
<svg t-name="TimelineView.Canvas" class="oe_timeline_view_canvas">
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
>
<polygon points="10 0, 10 7, 0 3.5" />
</marker>
</defs>
</svg>
</template>

View File

@ -1,106 +1,78 @@
/** @odoo-module alias=web_timeline.TimelineController **/ /** @odoo-module alias=web_timeline.TimelineController **/
/* Copyright 2023 Onestein - Anjeel Haria /**
* License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */ * Copyright 2023 Onestein - Anjeel Haria
import AbstractController from "web.AbstractController"; * 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 {FormViewDialog} from "@web/views/view_dialogs/form_view_dialog";
import time from "web.time"; import {Layout} from "@web/search/layout";
import core from "web.core"; import {SearchBar} from "@web/search/search_bar/search_bar";
import Dialog from "web.Dialog"; import {_t} from "@web/core/l10n/translation";
var _t = core._t; import {makeContext} from "@web/core/context";
import {Component} from "@odoo/owl"; 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({ const {DateTime} = luxon;
custom_events: _.extend({}, AbstractController.prototype.custom_events, {
onGroupClick: "_onGroupClick",
onItemDoubleClick: "_onItemDoubleClick",
onUpdate: "_onUpdate",
onRemove: "_onRemove",
onMove: "_onMove",
onAdd: "_onAdd",
}),
// Import time from "web.time";
export class TimelineController extends Component {
/** /**
* @override * @override
*/ */
init: function (parent, model, renderer, params) { setup() {
this._super.apply(this, arguments); this.rootRef = useRef("root");
this.open_popup_action = params.open_popup_action; this.model = useModel(this.props.Model, this.props.modelParams);
this.date_start = params.date_start; useSetupView({rootRef: useRef("root")});
this.date_stop = params.date_stop; this.searchBarToggler = useSearchBarToggler();
this.date_delay = params.date_delay; this.date_start = this.props.modelParams.date_start;
this.context = params.actionContext; 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.moveQueue = [];
this.debouncedInternalMove = _.debounce(this.internalMove, 0); this.debouncedInternalMove = useDebounced(this.internalMove, 0);
}, this.dialogService = useService("dialog");
on_detach_callback() { this.actionService = useService("action");
if (this.Dialog) {
this.Dialog();
this.Dialog = undefined;
} }
return this._super.apply(this, arguments); get rendererProps() {
}, return {
/** model: this.model,
* @override onAdd: this._onAdd.bind(this),
*/ onGroupClick: this._onGroupClick.bind(this),
update: function (params, options) { onItemDoubleClick: this._onItemDoubleClick.bind(this),
const res = this._super.apply(this, arguments); onMove: this._onMove.bind(this),
if (_.isEmpty(params)) { onRemove: this._onRemove.bind(this),
return res; onUpdate: this._onUpdate.bind(this),
};
} }
const defaults = _.defaults({}, options, { getSearchProps() {
adjust_window: true, const {comparision, context, domain, groupBy, orderBy} = this.env.searchModel;
}); return {comparision, context, domain, groupBy, orderBy};
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;
},
/** /**
* Gets triggered when a group in the timeline is * Gets triggered when a group in the timeline is
* clicked (by the TimelineRenderer). * clicked (by the TimelineRenderer).
* *
* @private * @private
* @param {EventObject} event * @param {EventObject} item
* @returns {jQuery.Deferred}
*/ */
_onGroupClick: function (event) { _onGroupClick(item) {
const groupField = this.renderer.last_group_bys[0]; const groupField = this.model.last_group_bys[0];
return this.do_action({ this.actionService.doAction({
type: "ir.actions.act_window", type: "ir.actions.act_window",
res_model: this.renderer.fields[groupField].relation, res_model: this.model.fields[groupField].relation,
res_id: event.data.item.group, res_id: item.group,
target: "new",
views: [[false, "form"]], views: [[false, "form"]],
view_mode: "form",
target: "new",
}); });
}, }
/** /**
* Triggered on double-click on an item in read-only mode (otherwise, we use _onUpdate). * 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 * @param {EventObject} event
* @returns {jQuery.Deferred} * @returns {jQuery.Deferred}
*/ */
_onItemDoubleClick: function (event) { _onItemDoubleClick(event) {
return this.openItem(event.data.item, false); return this.openItem(event.item, false);
}, }
/** /**
* Opens a form view of a clicked timeline * Opens a form view of a clicked timeline
* item (triggered by the TimelineRenderer). * item (triggered by the TimelineRenderer).
* *
* @private * @private
* @param {EventObject} event * @param {Object} item
* @returns {Object}
*/ */
_onUpdate: function (event) { _onUpdate(item) {
const item = event.data.item;
const item_id = Number(item.evt.id) || item.evt.id; const item_id = Number(item.evt.id) || item.evt.id;
return this.openItem(item_id, true); return this.openItem(item_id, true);
}, }
/** Open specified item, either through modal, or by navigating to form view. */ /** Open specified item, either through modal, or by navigating to form view.
openItem: function (item_id, is_editable) { * @param {Integer} item_id
* @param {Boolean} is_editable
*/
openItem(item_id, is_editable) {
if (this.open_popup_action) { if (this.open_popup_action) {
const options = { const options = {
resModel: this.model.modelName, resModel: this.model.model_name,
resId: item_id, resId: item_id,
context: this.getSession().user_context,
}; };
if (is_editable) { if (is_editable) {
options.onRecordSaved = () => this.write_completed(); options.onRecordSaved = async () => {
await this.model.load(this.getSearchProps());
this.render();
};
} else { } else {
options.preventEdit = true; options.preventEdit = true;
} }
this.Dialog = Component.env.services.dialog.add( this.Dialog = this.dialogService.add(FormViewDialog, options, {});
FormViewDialog,
options,
{}
);
} else { } else {
this.trigger_up("switch_view", { this.env.services.action.switchView("form", {
view_type: "form", resId: item_id,
model: this.model.modelName,
res_id: item_id,
mode: is_editable ? "edit" : "readonly", mode: is_editable ? "edit" : "readonly",
}); });
} }
}, }
/** /**
* Gets triggered when a timeline item is * Gets triggered when a timeline item is
* moved (triggered by the TimelineRenderer). * moved (triggered by the TimelineRenderer).
* *
* @private * @private
* @param {EventObject} event * @param {Object} item
* @param {Function} callback
*/ */
_onMove: function (event) { _onMove(item, callback) {
const item = event.data.item; const event_start = DateTime.fromJSDate(item.start);
const fields = this.renderer.fields; const event_end = item.end ? DateTime.fromJSDate(item.end) : false;
const event_start = item.start;
const event_end = item.end;
let group = false; let group = false;
if (item.group !== -1) { if (item.group !== -1) {
group = item.group; group = item.group;
@ -173,52 +143,34 @@ export default AbstractController.extend({
const data = {}; const data = {};
// In case of a move event, the date_delay stay the same, // In case of a move event, the date_delay stay the same,
// only date_start and stop must be updated // only date_start and stop must be updated
data[this.date_start] = time.auto_date_to_str( data[this.date_start] = this.model.serializeDate(this.date_start, event_start);
event_start,
fields[this.date_start].type
);
if (this.date_stop) { if (this.date_stop) {
// In case of instantaneous event, item.end is not defined // In case of instantaneous event, item.end is not defined
if (event_end) { if (event_end) {
data[this.date_stop] = time.auto_date_to_str( data[this.date_stop] = this.model.serializeDate(
event_end, this.date_stop,
fields[this.date_stop].type event_end
); );
} else { } else {
data[this.date_stop] = data[this.date_start]; data[this.date_stop] = data[this.date_start];
} }
} }
if (this.date_delay && event_end) { if (this.date_delay && event_end) {
const diff_seconds = Math.round( const diff = event_end.diff(event_start, "hours");
(event_end.getTime() - event_start.getTime()) / 1000 data[this.date_delay] = diff.hours;
);
data[this.date_delay] = diff_seconds / 3600;
} }
const grouped_field = this.renderer.last_group_bys[0]; const grouped_field = this.model.last_group_bys[0];
this._rpc({ if (this.model.fields[grouped_field].type !== "many2many") {
model: this.modelName, data[grouped_field] = group;
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({ this.moveQueue.push({
id: event.data.item.id, id: item.id,
data: data, data,
event: event, item,
callback,
}); });
this.debouncedInternalMove(); this.debouncedInternalMove();
}); }
},
/** /**
* Write enqueued moves to Odoo. After all writes are finished it updates * Write enqueued moves to Odoo. After all writes are finished it updates
* the view once (prevents flickering of the view when multiple timeline items * the view once (prevents flickering of the view when multiple timeline items
@ -226,153 +178,96 @@ export default AbstractController.extend({
* *
* @returns {jQuery.Deferred} * @returns {jQuery.Deferred}
*/ */
internalMove: function () { async internalMove() {
const queues = this.moveQueue.slice(); const queues = this.moveQueue.slice();
this.moveQueue = []; this.moveQueue = [];
const defers = [];
for (const item of queues) { for (const item of queues) {
defers.push( await this.model.write_completed(item.id, item.data);
this._rpc({ item.callback(item.item);
model: this.model.modelName, }
method: "write", await this.model.load(this.getSearchProps());
args: [[item.event.data.item.id], item.data], this.render();
context: this.getSession().user_context,
}).then(() => {
item.event.data.callback(item.event.data.item);
})
);
} }
return $.when.apply($, defers).done(() => {
this.write_completed({
adjust_window: false,
});
});
},
/** /**
* Triggered when a timeline item gets removed from the view. * Triggered when a timeline item gets removed from the view.
* Requires user confirmation before it gets actually deleted. * Requires user confirmation before it gets actually deleted.
* *
* @private * @private
* @param {EventObject} event * @param {Object} item
* @returns {jQuery.Deferred} * @param {Function} callback
*/ */
_onRemove: function (event) { _onRemove(item, callback) {
var def = $.Deferred(); this.dialogService.add(ConfirmationDialog, {
Dialog.confirm(this, _t("Are you sure you want to delete this record?"), {
title: _t("Warning"), title: _t("Warning"),
confirm_callback: () => { body: _t("Are you sure you want to delete this record?"),
this.remove_completed(event).then(def.resolve.bind(def)); 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. * Triggered when a timeline item gets added and opens a form view.
* *
* @private * @private
* @param {EventObject} event * @param {Object} item
* @returns {dialogs.FormViewDialog} * @param {Function} callback
*/ */
_onAdd: function (event) { _onAdd(item, callback) {
const item = event.data.item;
// Initialize default values for creation // Initialize default values for creation
const default_context = {}; const context = {};
default_context["default_".concat(this.date_start)] = item.start; 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) { if (this.date_delay) {
default_context["default_".concat(this.date_delay)] = 1; context[`default_${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");
} }
if (this.date_stop && item.end) { if (this.date_stop && item.end) {
default_context["default_".concat(this.date_stop)] = moment(item.end) item_end = DateTime.fromJSDate(item.end);
.utc() context[`default_${this.date_stop}`] = this.model.serializeDate(
.format("YYYY-MM-DD HH:mm:ss"); this.date_stop,
item_end
);
} }
if (this.date_delay && this.date_start && this.date_stop && item.end) { if (this.date_delay && this.date_stop && item_end) {
default_context["default_".concat(this.date_delay)] = const diff = item_end.diff(item_start, "hours");
(moment(item.end) - moment(item.start)) / 3600000; context[`default_${this.date_delay}`] = diff.hours;
} }
if (item.group > 0) { if (item.group > 0) {
default_context["default_".concat(this.renderer.last_group_bys[0])] = context[`default_${this.model.last_group_bys[0]}`] = item.group;
item.group;
} }
// Show popup // Show popup
this.Dialog = Component.env.services.dialog.add( this.dialogService.add(
FormViewDialog, FormViewDialog,
{ {
resId: false, resId: false,
context: _.extend(default_context, this.context), context: makeContext([context], this.env.searchModel.context),
onRecordSaved: (record) => this.create_completed([record.res_id]), onRecordSaved: async (record) => {
resModel: this.model.modelName, const new_record = await this.model.create_completed(record.resId);
callback(new_record);
}, },
{onClose: () => event.data.callback()} resModel: this.model.model_name,
},
{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)) { TimelineController.template = "web_timeline.TimelineView";
this.model.data.data.splice(unlink_index, 1); TimelineController.components = {Layout, SearchBar};
} TimelineController.props = {
event.data.callback(event.data.item); ...standardViewProps,
}); Model: Function,
}, modelParams: Object,
}); Renderer: Function,
};

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web_timeline.TimelineView">
<div t-att-class="props.className" t-ref="root">
<Layout
className="model.useSampleModel ? 'o_view_sample_data' : ''"
display="props.display"
>
<t t-set-slot="layout-actions">
<SearchBar t-if="searchBarToggler.state.showSearchBar" />
</t>
<t t-component="props.Renderer" t-props="rendererProps" />
</Layout>
</div>
</t>
</templates>

View File

@ -1,77 +1,227 @@
odoo.define("web_timeline.TimelineModel", function (require) { /** @odoo-module **/
"use strict"; /**
* 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({ export class TimelineModel extends Model {
init: function () { setup(params) {
this._super.apply(this, arguments); 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.keepLast = new KeepLast();
this.modelName = params.modelName; onWillStart(async () => {
this.fieldNames = params.fieldNames; this.write_right = await this.orm.call(
this.default_group_by = params.default_group_by; this.model_name,
if (!this.preload_def) { "check_access_rights",
this.preload_def = $.Deferred(); ["write", false]
$.when( );
this._rpc({ this.unlink_right = await this.orm.call(
model: this.modelName, this.model_name,
method: "check_access_rights", "check_access_rights",
args: ["write", false], ["unlink", false]
}), );
this._rpc({ this.create_right = await this.orm.call(
model: this.modelName, this.model_name,
method: "check_access_rights", "check_access_rights",
args: ["unlink", false], ["create", 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.data = {
domain: params.domain,
context: params.context,
};
return this.preload_def.then(this._loadTimeline.bind(this));
},
/** /**
* Read the records for the timeline. * 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 * @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;
}
}
let content = record.display_name;
if (this.recordTemplate) {
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();
}
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});
}
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;
}
/**
* 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} * @returns {jQuery.Deferred}
*/ */
_loadTimeline: function () { async remove_completed(event) {
return this._rpc({ await this.orm.call(this.model_name, "unlink", [[event.evt.id]]);
model: this.modelName, const unlink_index = this.data.findIndex((item) => item.id === event.evt.id);
method: "search_read", if (unlink_index !== -1) {
kwargs: { this.data.splice(unlink_index, 1);
fields: this.fieldNames, }
domain: this.data.domain, }
order: [{name: this.default_group_by}], /**
context: this.data.context, * Triggered upon completion of a new record.
}, * Updates the timeline view with the new record.
}).then((events) => { *
this.data.data = events; * @param {RecordId} id
this.data.rights = { * @returns {jQuery.Deferred}
unlink: this.unlink_right, */
create: this.create_right, async create_completed(id) {
write: this.write_right, const records = await this.orm.call(this.model_name, "read", [
}; [id],
}); this.params.fieldNames,
}, ]);
}); return this._event_data_transform(records[0]);
}
return TimelineModel; /**
}); * 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;
}
}

View File

@ -1,144 +1,124 @@
/* global vis, py */ /** @odoo-module **/
odoo.define("web_timeline.TimelineRenderer", function (require) { /* global vis */
"use strict"; /**
* 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 {DateTime} = luxon;
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 _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;
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.timeline = false;
this.initial_data_loaded = false; this.initial_data_loaded = false;
}, this.canvas_ref = $(renderToString("TimelineView.Canvas", {}));
onWillUpdateProps(async (props) => {
/** this.on_data_loaded(props.model.data);
* @override });
*/ onWillStart(async () => {
start: function () { await loadBundle("web_timeline.vis-timeline_lib");
const attrs = this.arch.attrs; });
this.$el.addClass(attrs.class); onMounted(() => {
this.$timeline = this.$(".oe_timeline_widget"); // Prevent Double Rendering on Updates
if (!this.timeline) {
if (!this.date_start) { this.init_timeline();
throw new Error( }
_t("Timeline view has not defined 'date_start' attribute.") this.on_attach_callback();
); });
} }
this._super.apply(this, arguments);
},
/** /**
* Triggered when the timeline is attached to the DOM. * Triggered when the timeline is attached to the DOM.
*/ */
on_attach_callback: function () { on_attach_callback() {
const $root = $(this.rootRef.el);
$root.addClass(this.params.class);
const height = const height =
this.$el.parent().height() - this.$(".oe_timeline_buttons").height(); $root.parent().height() - $root.find(".oe_timeline_buttons").height();
if (height > this.min_height && this.timeline) { if (height > this.min_height && this.timeline) {
this.timeline.setOptions({ this.timeline.setOptions({
height: height, height: height,
}); });
} }
},
/**
* @override
*/
_render: function () {
return Promise.resolve().then(() => {
// Prevent Double Rendering on Updates
if (!this.timeline) {
this.init_timeline();
} }
});
},
/** /**
* Set the timeline window to today (day). * Set the timeline window to today (day).
* *
* @private * @private
*/ */
_onTodayClicked: function () { _onTodayClicked() {
this.mode.data = "today";
if (this.timeline) { if (this.timeline) {
this.timeline.setWindow({ this.timeline.setWindow({
start: new moment(), start: DateTime.now().toJSDate(),
end: new moment().add(24, "hours"), end: DateTime.now().plus({hours: 24}).toJSDate(),
}); });
} }
}, }
/** /**
* Scale the timeline window to a day. * Scale the timeline window to a day.
* *
* @private * @private
*/ */
_onScaleDayClicked: function () { _onScaleDayClicked() {
this.mode.data = "day";
this._scaleCurrentWindow(() => 24); this._scaleCurrentWindow(() => 24);
}, }
/** /**
* Scale the timeline window to a week. * Scale the timeline window to a week.
* *
* @private * @private
*/ */
_onScaleWeekClicked: function () { _onScaleWeekClicked() {
this.mode.data = "week";
this._scaleCurrentWindow(() => 24 * 7); this._scaleCurrentWindow(() => 24 * 7);
}, }
/** /**
* Scale the timeline window to a month. * Scale the timeline window to a month.
* *
* @private * @private
*/ */
_onScaleMonthClicked: function () { _onScaleMonthClicked() {
this._scaleCurrentWindow((start) => 24 * moment(start).daysInMonth()); this.mode.data = "month";
}, this._scaleCurrentWindow((start) => 24 * start.daysInMonth);
}
/** /**
* Scale the timeline window to a year. * Scale the timeline window to a year.
* *
* @private * @private
*/ */
_onScaleYearClicked: function () { _onScaleYearClicked() {
this._scaleCurrentWindow( this.mode.data = "year";
(start) => 24 * (moment(start).isLeapYear() ? 366 : 365) this._scaleCurrentWindow((start) => 24 * (start.isInLeapYear ? 366 : 365));
); }
},
/** /**
* Scales the timeline window based on the current window. * Scales the timeline window based on the current window.
@ -147,45 +127,46 @@ odoo.define("web_timeline.TimelineRenderer", function (require) {
* (in hours) the window must be scaled to, starting from the "start" moment. * (in hours) the window must be scaled to, starting from the "start" moment.
* @private * @private
*/ */
_scaleCurrentWindow: function (getHoursFromStart) { _scaleCurrentWindow(getHoursFromStart) {
if (this.timeline) { if (this.timeline) {
const start = this.timeline.getWindow().start; const start = DateTime.fromJSDate(this.timeline.getWindow().start);
const end = moment(start).add(getHoursFromStart(start), "hours"); const end = start.plus({hours: getHoursFromStart(start)});
this.timeline.setWindow(start, end); this.timeline.setWindow(start.toJSDate(), end.toJSDate());
}
} }
},
/** /**
* Computes the initial visible window. * Computes the initial visible window.
* *
* @private * @private
*/ */
_computeMode: function () { _computeMode() {
if (this.mode) { if (this.mode.data) {
let start = false, let start = false,
end = false; end = false;
switch (this.mode) { const current_date = DateTime.now();
switch (this.mode.data) {
case "day": case "day":
start = new moment().startOf("day"); start = current_date.startOf("day");
end = new moment().endOf("day"); end = current_date.endOf("day");
break; break;
case "week": case "week":
start = new moment().startOf("week"); start = current_date.startOf("week");
end = new moment().endOf("week"); end = current_date.endOf("week");
break; break;
case "month": case "month":
start = new moment().startOf("month"); start = current_date.startOf("month");
end = new moment().endOf("month"); end = current_date.endOf("month");
break; break;
} }
if (end && start) { if (end && start) {
this.options.start = start; this.options.start = start.toJSDate();
this.options.end = end; this.options.end = end.toJSDate();
} else { } else {
this.mode = "fit"; this.mode.data = "fit";
}
} }
} }
},
/** /**
* Initializes the timeline * Initializes the timeline
@ -193,72 +174,61 @@ odoo.define("web_timeline.TimelineRenderer", function (require) {
* *
* @private * @private
*/ */
init_timeline: function () { init_timeline() {
this._computeMode(); this._computeMode();
this.options.editable = {}; this.options.editable = {};
if (this.can_update && this.modelClass.data.rights.write) { if (this.model.canEdit) {
this.options.onMove = this.on_move; this.options.onMove = this.on_move.bind(this);
this.options.onUpdate = this.on_update; this.options.onUpdate = this.on_update.bind(this);
// Drag items horizontally // Drag items horizontally
this.options.editable.updateTime = true; this.options.editable.updateTime = true;
// Drag items from one group to another // Drag items from one group to another
this.options.editable.updateGroup = true; this.options.editable.updateGroup = true;
if (this.can_create && this.modelClass.data.rights.create) { if (this.model.canCreate) {
this.options.onAdd = this.on_add; this.options.onAdd = this.on_add.bind(this);
// Add new items by double tapping // Add new items by double tapping
this.options.editable.add = true; this.options.editable.add = true;
} }
} }
if (this.can_delete && this.modelClass.data.rights.unlink) { if (this.model.canDelete) {
this.options.onRemove = this.on_remove; this.options.onRemove = this.on_remove.bind(this);
// Delete an item by tapping the delete button top right // Delete an item by tapping the delete button top right
this.options.editable.remove = true; this.options.editable.remove = true;
} }
this.options.xss = {disabled: true}; this.options.xss = {disabled: true};
this.qweb = new QWeb(session.debug, {_s: session.origin}, false); this.timeline = new vis.Timeline(this.canvasRef.el, {}, this.options);
if (this.arch.children.length) { this.timeline.on("click", this.on_timeline_click.bind(this));
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) { if (!this.options.onUpdate) {
// In read-only mode, catch double-clicks this way. // In read-only mode, catch double-clicks this way.
this.timeline.on("doubleClick", this.on_timeline_double_click); this.timeline.on("doubleClick", this.on_timeline_double_click.bind(this));
} }
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.$centerContainer = $(this.timeline.dom.centerContainer);
this.canvas = new TimelineCanvas(this); this.canvas = new TimelineCanvas(this.canvas_ref);
this.canvas.appendTo(this.$centerContainer); this.canvas_ref.appendTo(this.$centerContainer);
this.timeline.on("changed", () => { this.timeline.on("changed", () => {
this.draw_canvas(); this.draw_canvas();
this.load_initial_data(); this.load_initial_data();
}); });
}, }
/** /**
* Clears and draws the canvas items. * Clears and draws the canvas items.
* *
* @private * @private
*/ */
draw_canvas: function () { draw_canvas() {
this.canvas.clear(); this.canvas.clear();
if (this.dependency_arrow) { if (this.dependency_arrow) {
this.draw_dependencies(); this.draw_dependencies();
} }
}, }
/** /**
* Draw item dependencies on canvas. * Draw item dependencies on canvas.
* *
* @private * @private
*/ */
draw_dependencies: function () { draw_dependencies() {
const items = this.timeline.itemSet.items; const items = this.timeline.itemSet.items;
const datas = this.timeline.itemsData; const datas = this.timeline.itemsData;
if (!items || !datas) { if (!items || !datas) {
@ -277,7 +247,7 @@ odoo.define("web_timeline.TimelineRenderer", function (require) {
} }
} }
} }
}, }
/** /**
* Draws a dependency arrow between 2 timeline items. * Draws a dependency arrow between 2 timeline items.
@ -289,135 +259,86 @@ odoo.define("web_timeline.TimelineRenderer", function (require) {
* @param {Object} options.line_width The width of the line * @param {Object} options.line_width The width of the line
* @private * @private
*/ */
draw_dependency: function (from, to, options) { draw_dependency(from, to, options) {
if (!from.displayed || !to.displayed) { if (!from.displayed || !to.displayed) {
return; return;
} }
const defaults = _.defaults({}, options, { const defaults = Object.assign({line_color: "black", line_width: 1}, options);
line_color: "black",
line_width: 1,
});
this.canvas.draw_arrow( this.canvas.draw_arrow(
from.dom.box, from.dom.box,
to.dom.box, to.dom.box,
defaults.line_color, defaults.line_color,
defaults.line_width defaults.line_width
); );
}, }
/* Load initial data. This is called once after each redraw; we only handle the first one. /* Load initial data. This is called once after each redraw; we only handle the first one.
* Deferring this initial load here avoids rendering issues. */ * Deferring this initial load here avoids rendering issues. */
load_initial_data: function () { load_initial_data() {
if (!this.initial_data_loaded) { if (!this.initial_data_loaded) {
this.on_data_loaded(this.modelClass.data.data, this.last_group_bys); this.on_data_loaded(this.model.data);
this.initial_data_loaded = true; this.initial_data_loaded = true;
this.timeline.redraw(); 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
)
);
return this.on_data_loaded_2(nevents, group_bys, adjust_window);
});
},
/** /**
* Set groups and events. * Set groups and events.
* *
* @param {Object[]} events * @param {Object[]} records
* @param {String[]} group_bys
* @param {Boolean} adjust_window * @param {Boolean} adjust_window
* @private * @private
*/ */
on_data_loaded_2: function (events, group_bys, adjust_window) { async on_data_loaded(records, adjust_window) {
const data = []; const data = [];
this.grouped_by = group_bys; for (const record of records) {
for (const evt of events) { if (record[this.date_start]) {
if (evt[this.date_start]) { data.push(this.model._event_data_transform(record));
data.push(this.event_data_transform(evt));
} }
} }
this.split_groups(events, group_bys).then((groups) => { const groups = await this.split_groups(records);
this.timeline.setGroups(groups); this.timeline.setGroups(groups);
this.timeline.setItems(data); this.timeline.setItems(data);
const mode = !this.mode || this.mode === "fit"; const mode = !this.mode.data || this.mode.data === "fit";
const adjust = _.isUndefined(adjust_window) || adjust_window; const adjust = typeof adjust_window === "undefined" || adjust_window;
if (mode && adjust) { if (mode && adjust) {
this.timeline.fit(); this.timeline.fit();
} }
}); }
},
/** /**
* Get the groups. * Get the groups.
* *
* @param {Object[]} events * @param {Object[]} records
* @param {String[]} group_bys
* @private * @private
* @returns {Array} * @returns {Array}
*/ */
split_groups: async function (events, group_bys) { async split_groups(records) {
if (group_bys.length === 0) { if (this.model.last_group_bys.length === 0) {
return events; return records;
} }
const groups = []; const groups = [];
groups.push({id: -1, content: _t("<b>UNASSIGNED</b>"), order: -1}); groups.push({id: -1, content: _t("<b>UNASSIGNED</b>"), order: -1});
var seq = 1; var seq = 1;
for (const evt of events) { for (const evt of records) {
const grouped_field = _.first(group_bys); const grouped_field = this.model.last_group_bys[0];
const group_name = evt[grouped_field]; const group_name = evt[grouped_field];
if (group_name) { if (group_name && group_name instanceof Array) {
if (group_name instanceof Array) { const group = groups.find(
const group = _.find(
groups,
(existing_group) => existing_group.id === group_name[0] (existing_group) => existing_group.id === group_name[0]
); );
if (_.isUndefined(group)) { if (group) {
continue;
}
// Check if group is m2m in this case add id -> value of all // Check if group is m2m in this case add id -> value of all
// found entries. // found entries.
await this._rpc({ if (this.fields[grouped_field].type === "many2many") {
model: this.modelName, const list_values = await this.get_m2m_grouping_datas(
method: "fields_get", this.fields[grouped_field].relation,
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 group_name
); );
for (const vals of list_values) { for (const vals of list_values) {
let is_inside = false; const is_inside = groups.some((gr) => gr.id === vals.id);
for (const gr of groups) {
if (vals.id === gr.id) {
is_inside = true;
break;
}
}
if (!is_inside) { if (!is_inside) {
vals.order = seq; vals.order = seq;
seq += 1; seq += 1;
@ -432,182 +353,56 @@ odoo.define("web_timeline.TimelineRenderer", function (require) {
}); });
seq += 1; seq += 1;
} }
});
}
}
} }
} }
return groups; return groups;
}, }
get_m2m_grouping_datas: async function (model, group_name) { async get_m2m_grouping_datas(model, group_name) {
const groups = []; const groups = [];
for (const gr of group_name) { for (const gr of group_name) {
await this._rpc({ const record_info = await this.orm.call(model, "read", [
model: model, gr,
method: "name_get", ["display_name"],
args: [gr], ]);
context: this.getSession().user_context, groups.push({id: record_info[0].id, content: record_info[0].display_name});
}).then((name) => {
groups.push({id: name[0][0], content: name[0][1]});
});
} }
return groups; 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;
}
}
let content = evt.__name || evt.display_name;
if (this.arch.children.length) {
content = this.render_timeline_item(evt);
}
const r = {
start: date_start,
content: content,
id: evt.id,
order: evt.order,
group: group,
evt: evt,
style: `background-color: ${this.color};`,
};
// Only specify range end when there actually is one.
// ➔ Instantaneous events / those with inverted dates are displayed as points.
if (date_stop && moment(date_start).isBefore(date_stop)) {
r.end = date_stop;
}
this.color = null;
return r;
},
/**
* 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,
});
}
console.error(
_t('Template "timeline-item" not present in timeline view definition.')
);
},
/** /**
* Handle a click within the timeline. * Handle a click within the timeline.
* *
* @param {ClickEvent} e * @param {Object} e
* @private * @private
*/ */
on_timeline_click: function (e) { on_timeline_click(e) {
if (e.what === "group-label" && e.group !== -1) { if (e.what === "group-label" && e.group !== -1) {
this._trigger( this.props.onGroupClick(e);
e, }
() => {
// Do nothing
},
"onGroupClick"
);
} }
},
/** /**
* Handle a double-click within the timeline. * Handle a double-click within the timeline.
* *
* @param {ClickEvent} e * @param {Object} e
* @private * @private
*/ */
on_timeline_double_click: function (e) { on_timeline_double_click(e) {
if (e.what === "item" && e.item !== -1) { if (e.what === "item" && e.item !== -1) {
this._trigger( this.props.onItemDoubleClick(e);
e.item, }
() => {
// No callback
},
"onItemDoubleClick"
);
} }
},
/** /**
* Trigger onUpdate. * Trigger onUpdate.
* *
* @param {Object} item * @param {Object} item
* @param {Function} callback
* @private * @private
*/ */
on_update: function (item, callback) { on_update(item) {
this._trigger(item, callback, "onUpdate"); this.props.onUpdate(item);
}, }
/** /**
* Trigger onMove. * Trigger onMove.
@ -616,9 +411,9 @@ odoo.define("web_timeline.TimelineRenderer", function (require) {
* @param {Function} callback * @param {Function} callback
* @private * @private
*/ */
on_move: function (item, callback) { on_move(item, callback) {
this._trigger(item, callback, "onMove"); this.props.onMove(item, callback);
}, }
/** /**
* Trigger onRemove. * Trigger onRemove.
@ -627,9 +422,9 @@ odoo.define("web_timeline.TimelineRenderer", function (require) {
* @param {Function} callback * @param {Function} callback
* @private * @private
*/ */
on_remove: function (item, callback) { on_remove(item, callback) {
this._trigger(item, callback, "onRemove"); this.props.onRemove(item, callback);
}, }
/** /**
* Trigger onAdd. * Trigger onAdd.
@ -638,26 +433,18 @@ odoo.define("web_timeline.TimelineRenderer", function (require) {
* @param {Function} callback * @param {Function} callback
* @private * @private
*/ */
on_add: function (item, callback) { on_add(item, callback) {
this._trigger(item, callback, "onAdd"); this.props.onAdd(item, callback);
}, }
}
/** TimelineRenderer.template = "web_timeline.TimelineRenderer";
* Trigger_up encapsulation adds by default the renderer. TimelineRenderer.props = {
* model: Object,
* @param {HTMLElement} item onAdd: Function,
* @param {Function} callback onGroupClick: Function,
* @param {String} trigger onItemDoubleClick: Function,
* @private onMove: Function,
*/ onRemove: Function,
_trigger: function (item, callback, trigger) { onUpdate: Function,
this.trigger_up(trigger, { };
item: item,
callback: callback,
renderer: this,
});
},
});
return TimelineRenderer;
});

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web_timeline.TimelineRenderer">
<div class="oe_timeline_view" t-ref="root">
<div class="oe_timeline_buttons">
<button
t-att-class="'oe_timeline_button_today btn ' + (mode.data == 'today' ? ' btn-primary' : 'btn-default')"
t-on-click="_onTodayClicked"
>Today</button>
<div class="btn-group btn-sm">
<button
t-att-class="'oe_timeline_button_scale_day btn ' + (mode.data == 'day' ? ' btn-primary' : 'btn-default')"
t-on-click="_onScaleDayClicked"
>Day</button>
<button
t-att-class="'oe_timeline_button_scale_week btn ' + (mode.data == 'week' ? ' btn-primary' : 'btn-default')"
t-on-click="_onScaleWeekClicked"
>Week</button>
<button
t-att-class="'oe_timeline_button_scale_month btn ' + (mode.data == 'month' ? ' btn-primary' : 'btn-default')"
t-on-click="_onScaleMonthClicked"
>Month</button>
<button
t-att-class="'oe_timeline_button_scale_year btn ' + (mode.data == 'year' ? ' btn-primary' : 'btn-default')"
t-on-click="_onScaleYearClicked"
>Year</button>
</div>
</div>
<div class="oe_timeline_widget" t-ref="canvas" />
</div>
</t>
<svg t-name="TimelineView.Canvas" class="oe_timeline_view_canvas">
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
>
<polygon points="10 0, 10 7, 0 3.5" />
</marker>
</defs>
</svg>
</templates>

View File

@ -1,174 +1,48 @@
/* global py */ /** @odoo-module **/
/* Odoo web_timeline /* Odoo web_timeline
* Copyright 2015 ACSONE SA/NV * Copyright 2015 ACSONE SA/NV
* Copyright 2016 Pedro M. Baeza <pedro.baeza@tecnativa.com> * Copyright 2016 Pedro M. Baeza <pedro.baeza@tecnativa.com>
* Copyright 2023 Onestein - Anjeel Haria * Copyright 2023 Onestein - Anjeel Haria
* Copyright 2024 Tecnativa - Carlos López
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */ * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */
odoo.define("web_timeline.TimelineView", function (require) { import {TimelineArchParser} from "./timeline_arch_parser.esm";
"use strict"; 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 viewRegistry = registry.category("views");
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 _lt = core._lt; export const TimelineView = {
function isNullOrUndef(value) {
return _.isUndefined(value) || _.isNull(value);
}
function toBoolDefaultTrue(value) {
return isNullOrUndef(value) ? true : utils.toBoolElse(value, true);
}
var TimelineView = AbstractView.extend({
display_name: _lt("Timeline"), display_name: _lt("Timeline"),
icon: "fa fa-tasks", icon: "fa fa-tasks",
jsLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.js"], multiRecord: true,
cssLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.css"], ArchParser: TimelineArchParser,
config: _.extend({}, AbstractView.prototype.config, {
Model: TimelineModel,
Controller: TimelineController, Controller: TimelineController,
Renderer: TimelineRenderer, Renderer: TimelineRenderer,
}), Model: TimelineModel,
viewType: "timeline", jsLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.js"],
cssLibs: ["/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.css"],
type: "timeline",
/** props: (genericProps, view) => {
* @override const {arch, fields, resModel} = genericProps;
*/ const parser = new view.ArchParser();
init: function (viewInfo, params) { const archInfo = parser.parse(arch, fields);
this._super.apply(this, arguments); const modelParams = {
this.modelName = this.controllerParams.modelName; ...archInfo,
resModel: resModel,
fields: fields,
};
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 { return {
groupOrder: "order", ...genericProps,
orientation: {axis: "both", item: "top"}, modelParams,
selectable: true, Model: view.Model,
multiselect: true, Renderer: view.Renderer,
showCurrentTime: true,
stack: toBoolDefaultTrue(attrs.stack),
margin: attrs.margin ? JSON.parse(attrs.margin) : {item: 2},
zoomKey: attrs.zoomKey || "ctrlKey",
}; };
}, },
};
/** viewRegistry.add("timeline", TimelineView);
* 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;
});

View File

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<template>
<t t-name="TimelineView">
<div class="oe_timeline_view">
<div class="oe_timeline_buttons">
<button
class="btn btn-default btn-sm oe_timeline_button_today"
>Today</button>
<div class="btn-group btn-sm">
<button
class="btn btn-default oe_timeline_button_scale_day"
>Day</button>
<button
class="btn btn-default oe_timeline_button_scale_week"
>Week</button>
<button
class="btn btn-default oe_timeline_button_scale_month"
>Month</button>
<button
class="btn btn-default oe_timeline_button_scale_year"
>Year</button>
</div>
</div>
<div class="oe_timeline_widget" />
</div>
</t>
</template>

View File

@ -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"},
};

View File

@ -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 = `<timeline date_start="start_date" default_group_by="partner_id" ${paramName}="${paramValue}" />`;
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(`<timeline default_group_by="partner_id"/>`),
TimelineParseArchError
);
});
// eslint-disable-next-line no-undef
QUnit.test("throw if default_group_by is not set", (assert) => {
assert.throws(
() => parseArch(`<timeline date_start="date_start"/>`),
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(
`<timeline date_start="start_date" default_group_by="partner_id" mode="other" />`
);
}, TimelineParseArchError);
assert.throws(() => {
parseArch(
`<timeline date_start="start_date" default_group_by="partner_id" mode="" />`
);
}, TimelineParseArchError);
});
// eslint-disable-next-line no-undef
QUnit.test("colors", (assert) => {
const archInfo = parseArch(`
<timeline date_start="start_date" default_group_by="partner_id" colors="gray: state == 'cancel'; #ec7063: state == 'done'"/>
`);
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(`
<timeline date_start="start_date" default_group_by="partner_id">
<field name="other_field" />
<templates>
<t t-name="timeline-item">
<span t-out="record.other_field" />
</t>
</templates>
</timeline>
`);
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"
);
});

View File

@ -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: '<timeline date_start="date_start" date_stop="date_stop" default_group_by="partner_id"/>',
});
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: '<timeline date_start="date_start" date_stop="date_stop" default_group_by="partner_id"/>',
});
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: '<timeline date_start="date_start" date_stop="date_stop" default_group_by="partner_id"/>',
});
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: '<timeline date_start="date_start" date_stop="date_stop" default_group_by="partner_id"/>',
});
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: '<timeline date_start="date_start" date_stop="date_stop" default_group_by="partner_id" delete="0"/>',
});
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");
});
});

View File

@ -0,0 +1 @@
from . import test_web_timeline

View File

@ -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,
)