mirror of https://github.com/OCA/web.git
[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 testpull/3136/head
parent
614a3b133b
commit
293fead8d9
|
@ -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>`__:
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"}
|
||||||
|
)
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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">@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">@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">"Tasks"</span><span class="w">
|
</span><span class="na">string=</span><span class="s">"Tasks"</span><span class="w">
|
||||||
</span><span class="na">default_group_by=</span><span class="s">"project_id"</span><span class="w">
|
</span><span class="na">default_group_by=</span><span class="s">"project_id"</span><span class="w">
|
||||||
</span><span class="na">event_open_popup=</span><span class="s">"true"</span><span class="w">
|
</span><span class="na">event_open_popup=</span><span class="s">"true"</span><span class="w">
|
||||||
</span><span class="na">colors=</span><span class="s">"white: user_ids == []; #2ecb71: kanban_state == 'done'; #ec7063: kanban_state == 'blocked'"</span><span class="w">
|
</span><span class="na">colors=</span><span class="s">"white: user_ids == []; #2ecb71: state == '1_done'; #ec7063: state == '1_canceled'"</span><span class="w">
|
||||||
</span><span class="na">dependency_arrow=</span><span class="s">"depend_on_ids"</span><span class="w">
|
</span><span class="na">dependency_arrow=</span><span class="s">"depend_on_ids"</span><span class="w">
|
||||||
</span><span class="nt">></span><span class="w">
|
</span><span class="nt">></span><span class="w">
|
||||||
</span><span class="nt"><field</span><span class="w"> </span><span class="na">name=</span><span class="s">"user_ids"</span><span class="w"> </span><span class="nt">/></span><span class="w">
|
</span><span class="nt"><field</span><span class="w"> </span><span class="na">name=</span><span class="s">"user_ids"</span><span class="w"> </span><span class="nt">/></span><span class="w">
|
||||||
</span><span class="nt"><field</span><span class="w"> </span><span class="na">name=</span><span class="s">"planned_hours"</span><span class="w"> </span><span class="nt">/></span><span class="w">
|
</span><span class="nt"><field</span><span class="w"> </span><span class="na">name=</span><span class="s">"allocated_hours"</span><span class="w"> </span><span class="nt">/></span><span class="w">
|
||||||
</span><span class="nt"><templates></span><span class="w">
|
</span><span class="nt"><templates></span><span class="w">
|
||||||
</span><span class="nt"><t</span><span class="w"> </span><span class="na">t-name=</span><span class="s">"timeline-item"</span><span class="nt">></span><span class="w">
|
</span><span class="nt"><t</span><span class="w"> </span><span class="na">t-name=</span><span class="s">"timeline-item"</span><span class="nt">></span><span class="w">
|
||||||
</span><span class="nt"><div</span><span class="w"> </span><span class="na">class=</span><span class="s">"o_project_timeline_item"</span><span class="nt">></span><span class="w">
|
</span><span class="nt"><div</span><span class="w"> </span><span class="na">class=</span><span class="s">"o_project_timeline_item"</span><span class="nt">></span><span class="w">
|
||||||
</span><span class="nt"><t</span><span class="w"> </span><span class="na">t-foreach=</span><span class="s">"record.user_ids"</span><span class="w"> </span><span class="na">t-as=</span><span class="s">"user"</span><span class="nt">></span><span class="w">
|
</span><span class="nt"><t</span><span class="w"> </span><span class="na">t-foreach=</span><span class="s">"record.user_ids"</span><span class="w"> </span><span class="na">t-as=</span><span class="s">"user"</span><span class="w"> </span><span class="na">t-key=</span><span class="s">"user.id"</span><span class="nt">></span><span class="w">
|
||||||
</span><span class="nt"><img</span><span class="w">
|
</span><span class="nt"><img</span><span class="w">
|
||||||
</span><span class="na">t-if=</span><span class="s">"record.user_ids"</span><span class="w">
|
</span><span class="na">t-if=</span><span class="s">"record.user_ids"</span><span class="w">
|
||||||
</span><span class="na">t-attf-src=</span><span class="s">"/web/image/res.users/#{user}/image_128/16x16"</span><span class="w">
|
</span><span class="na">t-attf-src=</span><span class="s">"/web/image/res.users/#{user}/image_128/16x16"</span><span class="w">
|
||||||
|
@ -559,12 +561,12 @@ view example added onto cron tasks.</p>
|
||||||
</span><span class="nt"><t</span><span class="w"> </span><span class="na">t-esc=</span><span class="s">"record.display_name"</span><span class="w"> </span><span class="nt">/></span><span class="w">
|
</span><span class="nt"><t</span><span class="w"> </span><span class="na">t-esc=</span><span class="s">"record.display_name"</span><span class="w"> </span><span class="nt">/></span><span class="w">
|
||||||
</span><span class="nt"></span></span><span class="w">
|
</span><span class="nt"></span></span><span class="w">
|
||||||
</span><span class="nt"><small</span><span class="w">
|
</span><span class="nt"><small</span><span class="w">
|
||||||
</span><span class="na">name=</span><span class="s">"planned_hours"</span><span class="w">
|
</span><span class="na">name=</span><span class="s">"allocated_hours"</span><span class="w">
|
||||||
</span><span class="na">class=</span><span class="s">"text-info ml4"</span><span class="w">
|
</span><span class="na">class=</span><span class="s">"text-info ml4"</span><span class="w">
|
||||||
</span><span class="na">t-if=</span><span class="s">"record.planned_hours"</span><span class="w">
|
</span><span class="na">t-if=</span><span class="s">"record.allocated_hours"</span><span class="w">
|
||||||
</span><span class="nt">></span><span class="w">
|
</span><span class="nt">></span><span class="w">
|
||||||
</span><span class="nt"><t</span><span class="w">
|
</span><span class="nt"><t</span><span class="w">
|
||||||
</span><span class="na">t-esc=</span><span class="s">"field_utils.format.float_time(record.planned_hours)"</span><span class="w">
|
</span><span class="na">t-out=</span><span class="s">"formatters.get('float_time')(record.allocated_hours)"</span><span class="w">
|
||||||
</span><span class="nt">/></span><span class="w">
|
</span><span class="nt">/></span><span class="w">
|
||||||
</span><span class="nt"></small></span><span class="w">
|
</span><span class="nt"></small></span><span class="w">
|
||||||
</span><span class="nt"></div></span><span class="w">
|
</span><span class="nt"></div></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>
|
||||||
|
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
||||||
});
|
|
||||||
|
|
|
@ -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>
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
|
@ -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>
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
});
|
|
||||||
|
|
|
@ -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>
|
|
@ -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;
|
|
||||||
});
|
|
||||||
|
|
|
@ -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>
|
|
|
@ -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"},
|
||||||
|
};
|
|
@ -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"
|
||||||
|
);
|
||||||
|
});
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1 @@
|
||||||
|
from . import test_web_timeline
|
|
@ -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,
|
||||||
|
)
|
Loading…
Reference in New Issue