diff --git a/web_timeline/README.rst b/web_timeline/README.rst index e45ec342e..2d7c535f6 100644 --- a/web_timeline/README.rst +++ b/web_timeline/README.rst @@ -135,8 +135,10 @@ rendering: - ``record``: to access the fields values selected in the timeline definition. -- ``field_utils``: used to format and parse values (see available - functions in ``web.field_utils``). +- ``formatters``: used to format values (see available functions in + ``@web/views/fields/formatters``). +- ``parsers``: used to parse values (see available functions in + ``@web/views/fields/parsers``). You also need to declare the view in an action window of the involved model. @@ -159,15 +161,15 @@ More evolved example, from ``project_timeline``: string="Tasks" default_group_by="project_id" event_open_popup="true" - colors="white: user_ids == []; #2ecb71: kanban_state == 'done'; #ec7063: kanban_state == 'blocked'" + colors="white: user_ids == []; #2ecb71: state == '1_done'; #ec7063: state == '1_canceled'" dependency_arrow="depend_on_ids" > - +
- +
@@ -293,6 +295,7 @@ Contributors - Pedro M. Baeza - Alexandre Díaz - César A. Sánchez + - Carlos López - `Onestein `__: diff --git a/web_timeline/__manifest__.py b/web_timeline/__manifest__.py index c070c62dc..af36c1a9b 100644 --- a/web_timeline/__manifest__.py +++ b/web_timeline/__manifest__.py @@ -1,10 +1,11 @@ # Copyright 2016 ACSONE SA/NV () +# Copyright 2024 Tecnativa - Carlos Lopez # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { "name": "Web timeline", "summary": "Interactive visualization chart to show events in time", - "version": "16.0.1.1.5", + "version": "17.0.1.0.0", "development_status": "Production/Stable", "author": "ACSONE SA/NV, " "Tecnativa, " @@ -25,13 +26,24 @@ "web.assets_backend": [ "web_timeline/static/src/views/timeline/timeline_view.scss", "web_timeline/static/src/views/timeline/timeline_canvas.scss", + "web_timeline/static/src/views/timeline/timeline_arch_parser.esm.js", "web_timeline/static/src/views/timeline/timeline_view.esm.js", "web_timeline/static/src/views/timeline/timeline_renderer.esm.js", + "web_timeline/static/src/views/timeline/timeline_renderer.xml", "web_timeline/static/src/views/timeline/timeline_controller.esm.js", + "web_timeline/static/src/views/timeline/timeline_controller.xml", "web_timeline/static/src/views/timeline/timeline_model.esm.js", "web_timeline/static/src/views/timeline/timeline_canvas.esm.js", "web_timeline/static/src/views/timeline/timeline_view.xml", - "web_timeline/static/src/views/timeline/timeline_canvas.xml", + ], + "web_timeline.vis-timeline_lib": [ + "/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.js", + "/web_timeline/static/lib/vis-timeline/vis-timeline-graph2d.css", + ], + "web.qunit_suite_tests": [ + "web_timeline/static/tests/helpers.esm.js", + "web_timeline/static/tests/web_timeline_arch_parser_tests.esm.js", + "web_timeline/static/tests/web_timeline_view_tests.esm.js", ], }, } diff --git a/web_timeline/models/__init__.py b/web_timeline/models/__init__.py index 75544cd0a..7b1f92fd1 100644 --- a/web_timeline/models/__init__.py +++ b/web_timeline/models/__init__.py @@ -2,3 +2,4 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from . import ir_ui_view +from . import ir_action diff --git a/web_timeline/models/ir_action.py b/web_timeline/models/ir_action.py new file mode 100644 index 000000000..4328d2a55 --- /dev/null +++ b/web_timeline/models/ir_action.py @@ -0,0 +1,13 @@ +# Copyright 2024 Tecnativa - Carlos Lopez +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + +from .ir_ui_view import TIMELINE_VIEW + + +class ActWindowView(models.Model): + _inherit = "ir.actions.act_window.view" + + view_mode = fields.Selection( + selection_add=[TIMELINE_VIEW], ondelete={"timeline": "cascade"} + ) diff --git a/web_timeline/models/ir_ui_view.py b/web_timeline/models/ir_ui_view.py index 43432bbb0..93e16cb27 100644 --- a/web_timeline/models/ir_ui_view.py +++ b/web_timeline/models/ir_ui_view.py @@ -1,4 +1,5 @@ # Copyright 2016 ACSONE SA/NV () +# Copyright 2024 Tecnativa - Carlos Lopez # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import fields, models @@ -10,3 +11,6 @@ class IrUIView(models.Model): _inherit = "ir.ui.view" type = fields.Selection(selection_add=[TIMELINE_VIEW]) + + def _is_qweb_based_view(self, view_type): + return view_type == TIMELINE_VIEW[0] or super()._is_qweb_based_view(view_type) diff --git a/web_timeline/readme/CONFIGURE.md b/web_timeline/readme/CONFIGURE.md index c086f4098..a130c0508 100644 --- a/web_timeline/readme/CONFIGURE.md +++ b/web_timeline/readme/CONFIGURE.md @@ -20,10 +20,9 @@ render the timeline items. You have to name the template 'timeline-item'. These are the variables available in template rendering: -- `record`: to access the fields values selected in the timeline - definition. -- `field_utils`: used to format and parse values (see available - functions in `web.field_utils`). +- `record`: to access the fields values selected in the timeline definition. +- `formatters`: used to format values (see available functions in `@web/views/fields/formatters`). +- `parsers`: used to parse values (see available functions in `@web/views/fields/parsers`). You also need to declare the view in an action window of the involved model. @@ -45,15 +44,15 @@ More evolved example, from `project_timeline`: string="Tasks" default_group_by="project_id" event_open_popup="true" - colors="white: user_ids == []; #2ecb71: kanban_state == 'done'; #ec7063: kanban_state == 'blocked'" + colors="white: user_ids == []; #2ecb71: state == '1_done'; #ec7063: state == '1_canceled'" dependency_arrow="depend_on_ids" > - +
- +
diff --git a/web_timeline/readme/CONTRIBUTORS.md b/web_timeline/readme/CONTRIBUTORS.md index 449c90ae5..1d540e015 100644 --- a/web_timeline/readme/CONTRIBUTORS.md +++ b/web_timeline/readme/CONTRIBUTORS.md @@ -9,6 +9,7 @@ - Pedro M. Baeza - Alexandre Díaz - César A. Sánchez + - Carlos López - [Onestein](https://www.onestein.nl): - Dennis Sluijk \<\> - Anjeel Haria diff --git a/web_timeline/static/description/index.html b/web_timeline/static/description/index.html index facdd772d..a50e8dba3 100644 --- a/web_timeline/static/description/index.html +++ b/web_timeline/static/description/index.html @@ -516,8 +516,10 @@ rendering:

  • record: to access the fields values selected in the timeline definition.
  • -
  • field_utils: used to format and parse values (see available -functions in web.field_utils).
  • +
  • formatters: used to format values (see available functions in +@web/views/fields/formatters).
  • +
  • parsers: used to parse values (see available functions in +@web/views/fields/parsers).

You also need to declare the view in an action window of the involved model.

@@ -536,15 +538,15 @@ view example added onto cron tasks.

string="Tasks" default_group_by="project_id" event_open_popup="true" - colors="white: user_ids == []; #2ecb71: kanban_state == 'done'; #ec7063: kanban_state == 'blocked'" + colors="white: user_ids == []; #2ecb71: state == '1_done'; #ec7063: state == '1_canceled'" dependency_arrow="depend_on_ids" > <field name="user_ids" /> - <field name="planned_hours" /> + <field name="allocated_hours" /> <templates> <t t-name="timeline-item"> <div class="o_project_timeline_item"> - <t t-foreach="record.user_ids" t-as="user"> + <t t-foreach="record.user_ids" t-as="user" t-key="user.id"> <img t-if="record.user_ids" t-attf-src="/web/image/res.users/#{user}/image_128/16x16" @@ -559,12 +561,12 @@ view example added onto cron tasks.

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