diff --git a/web_timeline_gantt_ux/README.rst b/web_timeline_gantt_ux/README.rst new file mode 100644 index 00000000000..9a0521adee6 --- /dev/null +++ b/web_timeline_gantt_ux/README.rst @@ -0,0 +1,173 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +===================== +Web Timeline Gantt UX +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:192450072665c15bc0ea32e1da8e392c8bd6c7e1cb1dbcaa42afbd55684cc121 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/19.0/web_timeline_gantt_ux + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-19-0/web-19-0-web_timeline_gantt_ux + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module layers an Enterprise-style Gantt UX on top of +``web_timeline`` views, opt-in per view and implemented purely with +``patch()``: ``web_timeline`` itself is not modified and views that do +not opt in keep stock look and behavior. + +|Overview| + +Features: + +- **Row per record** under collapsible group header bands with record + counts; collapse state persists to localStorage. Record names render + on the bars and overflow narrow pills (Enterprise-style labels); the + sidebar carries only the group bands. +- **Light theme via CSS design tokens**: the view's ``colors=`` rules + render as harmonized pills, weekends and the today column are shaded, + today's axis label becomes a pill, and the window stays anchored: the + view never re-zooms after an edit. +- **Dependency arrows** drawn as Bezier curves from predecessor end to + successor start, with hover tooltips. + +|Dependency chains and fan-out| + +- **Drag-to-link**: hover a bar and drag the circular handle at its end + onto another bar to create the dependency. Client-side validation + rejects self/duplicate/reverse links; a dashed pending arrow shows + while the write is in flight; the success toast offers Undo; + per-record gating via an ``allow_task_dependencies``-style related + field is respected when the model exposes one. + +|Link handle| + +- **Arrow removal**: click an arrow (or focus it and press Delete) and + confirm. +- **Dependency-aware moves**: dragging a bar whose record has dependents + asks whether the downstream chain shifts by the same delta, the record + moves alone, or the move is cancelled (the bar snaps back). + Multi-select drags get a single prompt. + +|Status colors| + +.. |Overview| image:: https://raw.githubusercontent.com/OCA/web/19.0/web_timeline_gantt_ux/static/description/gallery_overview.png +.. |Dependency chains and fan-out| image:: https://raw.githubusercontent.com/OCA/web/19.0/web_timeline_gantt_ux/static/description/gallery_fanout.png +.. |Link handle| image:: https://raw.githubusercontent.com/OCA/web/19.0/web_timeline_gantt_ux/static/description/gallery_handle.png +.. |Status colors| image:: https://raw.githubusercontent.com/OCA/web/19.0/web_timeline_gantt_ux/static/description/gallery_status_colors.png + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Opt a timeline view in with a single arch attribute (optionally with +status colors that the theme harmonizes): + +.. code:: xml + + + +Everything else is automatic: + +- One row per record, grouped under collapsible bands of the + ``default_group_by`` field. Click a band to collapse or expand it; + click a row to open the record. +- Drag a bar to reschedule (snaps to whole days); if other records + depend on it, a dialog asks whether to shift them along. +- To create a dependency, hover the predecessor bar and drag the circle + at its right end onto the successor. To remove one, click its arrow + and confirm. + +Views without ``gantt_ux="true"`` keep stock ``web_timeline`` behavior. + +Known issues / Roadmap +====================== + +- Two-click linking (select source, click target) as a + keyboard-accessible alternative to the pointer drag. +- Touch support for the hover-born link handles. +- Dark theme variant (the token block makes this a one-block addition). +- Dependency-aware moves currently shift only records loaded in the + current search domain. +- Many2many group-by uses the first value only (matches stock + ``web_timeline`` item behavior). + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Cubert GmbH + +Contributors +------------ + +- Nima Ghorbani + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-nghorbani| image:: https://github.com/nghorbani.png?size=40px + :target: https://github.com/nghorbani + :alt: nghorbani + +Current `maintainer `__: + +|maintainer-nghorbani| + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_timeline_gantt_ux/__init__.py b/web_timeline_gantt_ux/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web_timeline_gantt_ux/__manifest__.py b/web_timeline_gantt_ux/__manifest__.py new file mode 100644 index 00000000000..af9e1cf9f59 --- /dev/null +++ b/web_timeline_gantt_ux/__manifest__.py @@ -0,0 +1,37 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Web Timeline Gantt UX", + "summary": "Enterprise-style Gantt layout, theme and interactive dependency" + " editing for web_timeline views (opt-in per view via gantt_ux)", + "version": "19.0.1.0.0", + "development_status": "Beta", + "category": "web", + "author": "Cubert GmbH, Odoo Community Association (OCA)", + "maintainers": ["nghorbani"], + "website": "https://github.com/OCA/web", + "license": "AGPL-3", + "depends": ["web_timeline"], + "installable": True, + "assets": { + "web.assets_backend": [ + "web_timeline_gantt_ux/static/src/core/bezier.esm.js", + "web_timeline_gantt_ux/static/src/core/arch_parser_patch.esm.js", + "web_timeline_gantt_ux/static/src/gantt_layout/gantt_theme.scss", + "web_timeline_gantt_ux/static/src/gantt_layout/model_patch.esm.js", + "web_timeline_gantt_ux/static/src/gantt_layout/canvas_patch.esm.js", + "web_timeline_gantt_ux/static/src/gantt_layout/canvas_defs.xml", + "web_timeline_gantt_ux/static/src/gantt_layout/renderer_layout_patch.esm.js", + "web_timeline_gantt_ux/static/src/gantt_layout/controller_layout_patch.esm.js", + "web_timeline_gantt_ux/static/src/dependency_edit/dependency_edit.scss", + "web_timeline_gantt_ux/static/src/dependency_edit/dependency_link_dragger.esm.js", + "web_timeline_gantt_ux/static/src/dependency_edit/renderer_dep_patch.esm.js", + "web_timeline_gantt_ux/static/src/dependency_edit/controller_dep_patch.esm.js", + "web_timeline_gantt_ux/static/src/dependency_edit/move_cascade.esm.js", + "web_timeline_gantt_ux/static/src/dependency_edit/move_cascade_dialog.xml", + "web_timeline_gantt_ux/static/src/dependency_edit/move_cascade_patch.esm.js", + ], + "web.assets_unit_tests": [ + "web_timeline_gantt_ux/static/tests/**/*.test.js", + ], + }, +} diff --git a/web_timeline_gantt_ux/pyproject.toml b/web_timeline_gantt_ux/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/web_timeline_gantt_ux/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_timeline_gantt_ux/readme/CONTRIBUTORS.md b/web_timeline_gantt_ux/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..ebdf096f407 --- /dev/null +++ b/web_timeline_gantt_ux/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Nima Ghorbani \ diff --git a/web_timeline_gantt_ux/readme/DESCRIPTION.md b/web_timeline_gantt_ux/readme/DESCRIPTION.md new file mode 100644 index 00000000000..9cd97396c5c --- /dev/null +++ b/web_timeline_gantt_ux/readme/DESCRIPTION.md @@ -0,0 +1,39 @@ +This module layers an Enterprise-style Gantt UX on top of `web_timeline` +views, opt-in per view and implemented purely with `patch()`: `web_timeline` +itself is not modified and views that do not opt in keep stock look and +behavior. + +![Overview](../static/description/gallery_overview.png) + +Features: + +- **Row per record** under collapsible group header bands with record counts; + collapse state persists to localStorage. Record names render on the bars + and overflow narrow pills (Enterprise-style labels); the sidebar carries + only the group bands. +- **Light theme via CSS design tokens**: the view's `colors=` rules render as + harmonized pills, weekends and the today column are shaded, today's axis + label becomes a pill, and the window stays anchored: the view never + re-zooms after an edit. +- **Dependency arrows** drawn as Bezier curves from predecessor end to + successor start, with hover tooltips. + +![Dependency chains and fan-out](../static/description/gallery_fanout.png) + +- **Drag-to-link**: hover a bar and drag the circular handle at its end onto + another bar to create the dependency. Client-side validation rejects + self/duplicate/reverse links; a dashed pending arrow shows while the write + is in flight; the success toast offers Undo; per-record gating via an + `allow_task_dependencies`-style related field is respected when the model + exposes one. + +![Link handle](../static/description/gallery_handle.png) + +- **Arrow removal**: click an arrow (or focus it and press Delete) and + confirm. +- **Dependency-aware moves**: dragging a bar whose record has dependents asks + whether the downstream chain shifts by the same delta, the record moves + alone, or the move is cancelled (the bar snaps back). Multi-select drags + get a single prompt. + +![Status colors](../static/description/gallery_status_colors.png) diff --git a/web_timeline_gantt_ux/readme/ROADMAP.md b/web_timeline_gantt_ux/readme/ROADMAP.md new file mode 100644 index 00000000000..0a537d8ec0d --- /dev/null +++ b/web_timeline_gantt_ux/readme/ROADMAP.md @@ -0,0 +1,8 @@ +- Two-click linking (select source, click target) as a keyboard-accessible + alternative to the pointer drag. +- Touch support for the hover-born link handles. +- Dark theme variant (the token block makes this a one-block addition). +- Dependency-aware moves currently shift only records loaded in the current + search domain. +- Many2many group-by uses the first value only (matches stock `web_timeline` + item behavior). diff --git a/web_timeline_gantt_ux/readme/USAGE.md b/web_timeline_gantt_ux/readme/USAGE.md new file mode 100644 index 00000000000..f32bcde999b --- /dev/null +++ b/web_timeline_gantt_ux/readme/USAGE.md @@ -0,0 +1,26 @@ +Opt a timeline view in with a single arch attribute (optionally with status +colors that the theme harmonizes): + +```xml + +``` + +Everything else is automatic: + +- One row per record, grouped under collapsible bands of the + `default_group_by` field. Click a band to collapse or expand it; click a + row to open the record. +- Drag a bar to reschedule (snaps to whole days); if other records depend on + it, a dialog asks whether to shift them along. +- To create a dependency, hover the predecessor bar and drag the circle at + its right end onto the successor. To remove one, click its arrow and + confirm. + +Views without `gantt_ux="true"` keep stock `web_timeline` behavior. diff --git a/web_timeline_gantt_ux/static/description/gallery_fanout.png b/web_timeline_gantt_ux/static/description/gallery_fanout.png new file mode 100644 index 00000000000..44281fe565b Binary files /dev/null and b/web_timeline_gantt_ux/static/description/gallery_fanout.png differ diff --git a/web_timeline_gantt_ux/static/description/gallery_handle.png b/web_timeline_gantt_ux/static/description/gallery_handle.png new file mode 100644 index 00000000000..5f1c7fcfc58 Binary files /dev/null and b/web_timeline_gantt_ux/static/description/gallery_handle.png differ diff --git a/web_timeline_gantt_ux/static/description/gallery_overview.png b/web_timeline_gantt_ux/static/description/gallery_overview.png new file mode 100644 index 00000000000..2b84cdc710a Binary files /dev/null and b/web_timeline_gantt_ux/static/description/gallery_overview.png differ diff --git a/web_timeline_gantt_ux/static/description/gallery_status_colors.png b/web_timeline_gantt_ux/static/description/gallery_status_colors.png new file mode 100644 index 00000000000..a90b5ac576a Binary files /dev/null and b/web_timeline_gantt_ux/static/description/gallery_status_colors.png differ diff --git a/web_timeline_gantt_ux/static/description/icon.png b/web_timeline_gantt_ux/static/description/icon.png new file mode 100644 index 00000000000..d01494920cc Binary files /dev/null and b/web_timeline_gantt_ux/static/description/icon.png differ diff --git a/web_timeline_gantt_ux/static/description/index.html b/web_timeline_gantt_ux/static/description/index.html new file mode 100644 index 00000000000..9ca83095c8e --- /dev/null +++ b/web_timeline_gantt_ux/static/description/index.html @@ -0,0 +1,509 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Web Timeline Gantt UX

+ +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

This module layers an Enterprise-style Gantt UX on top of +web_timeline views, opt-in per view and implemented purely with +patch(): web_timeline itself is not modified and views that do +not opt in keep stock look and behavior.

+

Overview

+

Features:

+
    +
  • Row per record under collapsible group header bands with record +counts; collapse state persists to localStorage. Record names render +on the bars and overflow narrow pills (Enterprise-style labels); the +sidebar carries only the group bands.
  • +
  • Light theme via CSS design tokens: the view’s colors= rules +render as harmonized pills, weekends and the today column are shaded, +today’s axis label becomes a pill, and the window stays anchored: the +view never re-zooms after an edit.
  • +
  • Dependency arrows drawn as Bezier curves from predecessor end to +successor start, with hover tooltips.
  • +
+

Dependency chains and fan-out

+
    +
  • Drag-to-link: hover a bar and drag the circular handle at its end +onto another bar to create the dependency. Client-side validation +rejects self/duplicate/reverse links; a dashed pending arrow shows +while the write is in flight; the success toast offers Undo; +per-record gating via an allow_task_dependencies-style related +field is respected when the model exposes one.
  • +
+

Link handle

+
    +
  • Arrow removal: click an arrow (or focus it and press Delete) and +confirm.
  • +
  • Dependency-aware moves: dragging a bar whose record has dependents +asks whether the downstream chain shifts by the same delta, the record +moves alone, or the move is cancelled (the bar snaps back). +Multi-select drags get a single prompt.
  • +
+

Status colors

+

Table of contents

+ +
+

Usage

+

Opt a timeline view in with a single arch attribute (optionally with +status colors that the theme harmonizes):

+
+<timeline
+    date_start="planned_date_start"
+    date_stop="planned_date_end"
+    default_group_by="project_id"
+    dependency_arrow="depend_on_ids"
+    colors="#ffffff: user_ids == []; #a8dbc0: state == '1_done'; #f0b9b3: state == '1_canceled'"
+    gantt_ux="true"
+/>
+
+

Everything else is automatic:

+
    +
  • One row per record, grouped under collapsible bands of the +default_group_by field. Click a band to collapse or expand it; +click a row to open the record.
  • +
  • Drag a bar to reschedule (snaps to whole days); if other records +depend on it, a dialog asks whether to shift them along.
  • +
  • To create a dependency, hover the predecessor bar and drag the circle +at its right end onto the successor. To remove one, click its arrow +and confirm.
  • +
+

Views without gantt_ux="true" keep stock web_timeline behavior.

+
+
+

Known issues / Roadmap

+
    +
  • Two-click linking (select source, click target) as a +keyboard-accessible alternative to the pointer drag.
  • +
  • Touch support for the hover-born link handles.
  • +
  • Dark theme variant (the token block makes this a one-block addition).
  • +
  • Dependency-aware moves currently shift only records loaded in the +current search domain.
  • +
  • Many2many group-by uses the first value only (matches stock +web_timeline item behavior).
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Cubert GmbH
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

nghorbani

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/web_timeline_gantt_ux/static/src/core/arch_parser_patch.esm.js b/web_timeline_gantt_ux/static/src/core/arch_parser_patch.esm.js new file mode 100644 index 00000000000..90bd40358e5 --- /dev/null +++ b/web_timeline_gantt_ux/static/src/core/arch_parser_patch.esm.js @@ -0,0 +1,77 @@ +/** @odoo-module **/ + +import {TimelineArchParser} from "@web_timeline/views/timeline/timeline_arch_parser.esm"; +import {exprToBoolean} from "@web/core/utils/strings"; +import {patch} from "@web/core/utils/patch"; +import {visitXML} from "@web/core/utils/xml"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Round a dragged date to the nearest local midnight (tasks are planned in + * whole days; sub-day offsets are noise that leaks into forms and reports). + * + * @param {Date} date candidate date from a drag/resize + * @returns {Date} + */ +export function snapToDay(date) { + const snapped = new Date(date.getTime()); + snapped.setHours(0, 0, 0, 0); + if (date.getHours() >= 12) { + snapped.setDate(snapped.getDate() + 1); + } + return snapped; +} + +/** + * Vis options applied when a opts into gantt mode. Kept as an + * exported pure function so it is unit-testable without a view. + * + * The initial window is deliberately NOT set here: the stock renderer + * re-fits/re-windows after construction, so the renderer patch owns the + * window in on_attach_callback. + * + * @param {Object} archInfo parsed arch info (mutated) + * @param {Element} timelineNode the arch node + * @returns {Object} archInfo + */ +export function applyGanttOptions(archInfo, timelineNode) { + if (!timelineNode.hasAttribute("stack")) { + archInfo.options.stack = false; + } + if (!timelineNode.hasAttribute("margin")) { + archInfo.options.margin = {item: 6, axis: 8}; + } + Object.assign(archInfo.options, { + orientation: {axis: "top", item: "top"}, + verticalScroll: true, + dataAttributes: ["id"], + zoomMin: 3 * DAY_MS, + zoomMax: 2 * 365 * DAY_MS, + snap: snapToDay, + format: { + minorLabels: {day: "dd D", weekday: "dd D"}, + majorLabels: {day: "MMMM YYYY", weekday: "MMMM YYYY", month: "YYYY"}, + }, + }); + return archInfo; +} + +patch(TimelineArchParser.prototype, { + parse(arch) { + const archInfo = super.parse(...arguments); + let timelineNode = null; + visitXML(arch, (node) => { + if (node.tagName === "timeline" && !timelineNode) { + timelineNode = node; + } + }); + archInfo.gantt_ux = Boolean( + timelineNode && exprToBoolean(timelineNode.getAttribute("gantt_ux") || "") + ); + if (archInfo.gantt_ux) { + applyGanttOptions(archInfo, timelineNode); + } + return archInfo; + }, +}); diff --git a/web_timeline_gantt_ux/static/src/core/bezier.esm.js b/web_timeline_gantt_ux/static/src/core/bezier.esm.js new file mode 100644 index 00000000000..dd9d1d1fbb4 --- /dev/null +++ b/web_timeline_gantt_ux/static/src/core/bezier.esm.js @@ -0,0 +1,40 @@ +/** @odoo-module **/ + +const MIN_OFFSET = 16; +const MAX_OFFSET = 60; + +/** + * Cubic Bézier path between a predecessor's right edge and a successor's left + * edge, in the same coordinate space (the .vis-center panel). + * + * Two regimes: + * - Forward link (successor starts comfortably after the predecessor ends): + * a single cubic with horizontal tangents. + * - Backward/tight link (successor starts at or before the predecessor's + * end): an S-route out to the right, through a mid line, back to the left. + * When both anchors sit on the same row, the mid line detours one row + * below so the curve does not run through the bar itself. + * + * @param {Number} x1 predecessor right edge x + * @param {Number} y1 predecessor vertical center y + * @param {Number} x2 successor left edge x + * @param {Number} y2 successor vertical center y + * @param {Number} rowHeight row rhythm used for the same-row detour + * @returns {String} SVG path "d" attribute + */ +export function bezierPathD(x1, y1, x2, y2, rowHeight = 36) { + const dx = x2 - x1; + if (dx >= 2 * MIN_OFFSET) { + const off = Math.max(MIN_OFFSET, Math.min(MAX_OFFSET, dx / 2)); + return `M${x1},${y1} C${x1 + off},${y1} ${x2 - off},${y2} ${x2},${y2}`; + } + const off = Math.max(MIN_OFFSET, Math.min(MAX_OFFSET, Math.abs(dx))); + const sameRow = Math.abs(y2 - y1) < rowHeight; + const midY = sameRow ? y1 + rowHeight : (y1 + y2) / 2; + const midX = (x1 + x2) / 2; + return ( + `M${x1},${y1} ` + + `C${x1 + off},${y1} ${x1 + off},${midY} ${midX},${midY} ` + + `C${x2 - off},${midY} ${x2 - off},${y2} ${x2},${y2}` + ); +} diff --git a/web_timeline_gantt_ux/static/src/dependency_edit/controller_dep_patch.esm.js b/web_timeline_gantt_ux/static/src/dependency_edit/controller_dep_patch.esm.js new file mode 100644 index 00000000000..55c9612ccd2 --- /dev/null +++ b/web_timeline_gantt_ux/static/src/dependency_edit/controller_dep_patch.esm.js @@ -0,0 +1,127 @@ +/** @odoo-module **/ + +import {ConfirmationDialog} from "@web/core/confirmation_dialog/confirmation_dialog"; +import {TimelineController} from "@web_timeline/views/timeline/timeline_controller.esm"; +import {_t} from "@web/core/l10n/translation"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; + +const UNDO_TOAST_MS = 8000; + +patch(TimelineController.prototype, { + setup() { + super.setup(...arguments); + this.tlgNotification = useService("notification"); + }, + + get rendererProps() { + const props = super.rendererProps; + if (this.props.modelParams.gantt_ux) { + props.onCreateDependency = this._onCreateDependency.bind(this); + props.onRemoveDependency = this._onRemoveDependency.bind(this); + } + return props; + }, + + _tlgDisplayName(id) { + const record = (this.model.data || []).find((rec) => rec.id === id); + return record?.display_name || `#${id}`; + }, + + /** + * Create "successor is blocked by predecessor" and offer an immediate + * Undo in the success toast — arrows can be unreachable under bars, so + * the toast is the guaranteed recovery path for a misdrop. + * + * @param {Object} ids + * @param {Number} ids.predecessorId + * @param {Number} ids.successorId + * @returns {Promise} true when the write landed + */ + async _onCreateDependency({predecessorId, successorId}) { + const successorName = this._tlgDisplayName(successorId); + const predecessorName = this._tlgDisplayName(predecessorId); + const created = await this._tlgWriteDependency(successorId, [ + [4, predecessorId], + ]); + if (created) { + const close = this.tlgNotification.add( + _t('"%s" is now blocked by "%s".', successorName, predecessorName), + { + type: "success", + sticky: true, + buttons: [ + { + name: _t("Undo"), + onClick: async () => { + close(); + await this._tlgWriteDependency(successorId, [ + [3, predecessorId], + ]); + }, + }, + ], + } + ); + setTimeout(close, UNDO_TOAST_MS); + } + return created; + }, + + _onRemoveDependency({predecessorId, successorId}) { + const successorName = this._tlgDisplayName(successorId); + const predecessorName = this._tlgDisplayName(predecessorId); + this.dialogService.add(ConfirmationDialog, { + title: _t("Remove dependency"), + body: _t( + '"%s" is blocked by "%s". Remove this dependency?', + successorName, + predecessorName + ), + confirmLabel: _t("Remove"), + cancelLabel: _t("Discard"), + confirm: () => this._tlgWriteDependency(successorId, [[3, predecessorId]]), + cancel: () => { + return; + }, + }); + }, + + /** + * Single write path for dependency link/unlink. User-facing server + * rejections (cycles are enforced by + * project.task._check_no_cyclic_dependencies) surface as a danger toast + * without a reload; unexpected errors go to the crash handler. + * + * @param {Number} successorId record owning the m2m + * @param {Array} commands ORM commands ([[4, id]] / [[3, id]]) + * @returns {Promise} true when the write landed + */ + async _tlgWriteDependency(successorId, commands) { + const depField = this.model.params.dependency_arrow; + try { + await this.model.write_completed(successorId, { + [depField]: commands, + }); + } catch (error) { + const exceptionName = error?.exceptionName || error?.data?.name || ""; + const isUserFacing = [ + "odoo.exceptions.ValidationError", + "odoo.exceptions.UserError", + ].includes(exceptionName); + if (!isUserFacing) { + throw error; + } + this.tlgNotification.add(error.data?.message || error.message, { + title: _t("Dependency not saved"), + type: "danger", + }); + return false; + } + // The gantt renderer suppresses the stock auto-fit, so this reload + // does not move the window. + await this.model.load(this.getSearchProps()); + this.render(); + return true; + }, +}); diff --git a/web_timeline_gantt_ux/static/src/dependency_edit/dependency_edit.scss b/web_timeline_gantt_ux/static/src/dependency_edit/dependency_edit.scss new file mode 100644 index 00000000000..dda2caffd1c --- /dev/null +++ b/web_timeline_gantt_ux/static/src/dependency_edit/dependency_edit.scss @@ -0,0 +1,72 @@ +// Interactive dependency editing (drag-to-link + arrow removal), active only +// on gantt_ux views with edit rights (.o_tlg_dep_editable set by the +// renderer patch). + +.o_timeline_gantt { + .o_tlg_overlay { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 4; + } + // 28px hit zone overlapping the pill's right edge (positioned by the + // dragger); the visual is a 14px disc centered inside it. The white halo + // keeps the disc readable over bar labels that overflow the pill. + .o_tlg_handle { + position: absolute; + width: 28px; + height: 28px; + pointer-events: auto; + cursor: crosshair; + touch-action: none; + z-index: 5; + + &::after { + content: ""; + position: absolute; + inset: 7px; + border-radius: 50%; + background: #fff; + border: 2px solid var(--o-tlg-accent); + box-sizing: border-box; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9); + } + } + .o_tlg_ghost { + stroke: var(--o-tlg-accent); + stroke-width: 1.5; + stroke-dasharray: 4 3; + fill: none; + pointer-events: none; + } + .vis-item.o_tlg_drop_ok { + outline: 2px solid var(--o-tlg-accent); + outline-offset: 1px; + } + .vis-item.o_tlg_drop_invalid { + outline: 2px solid var(--o-tlg-danger); + outline-offset: 1px; + cursor: not-allowed !important; + } + + // While a link drag is active: crosshair everywhere, the handle and the + // existing arrows become transparent to hit-testing (elementFromPoint + // must reach the bars, not a crossing arrow's 10px corridor). + &.o_tlg_linking { + .vis-timeline, + .vis-timeline * { + cursor: crosshair !important; + } + .vis-item.o_tlg_drop_invalid, + .vis-item.o_tlg_drop_invalid * { + cursor: not-allowed !important; + } + .o_tlg_handle { + pointer-events: none; + } + .o_tlg_dep_hit, + .o_tlg_dep_arrow { + pointer-events: none !important; + } + } +} diff --git a/web_timeline_gantt_ux/static/src/dependency_edit/dependency_link_dragger.esm.js b/web_timeline_gantt_ux/static/src/dependency_edit/dependency_link_dragger.esm.js new file mode 100644 index 00000000000..89d91355a78 --- /dev/null +++ b/web_timeline_gantt_ux/static/src/dependency_edit/dependency_link_dragger.esm.js @@ -0,0 +1,395 @@ +/** @odoo-module **/ + +import {_t} from "@web/core/l10n/translation"; +import {bezierPathD} from "@web_timeline_gantt_ux/core/bezier.esm"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +// The handle is a 28px hit zone (14px visual disc centered via CSS). On +// UNSELECTED bars it overlaps the pill's right edge by 2px so the pointer +// path bar -> handle never crosses dead space. On SELECTED bars — the only +// state where vis renders resize handles — it instead sits fully OUTSIDE +// vis's .vis-drag-right zone (which extends 4px past the edge and shrinks +// to cover the whole right end of short pills); any overlap there would +// steal the entire resize affordance of a 1-2 day bar. +const HANDLE_SIZE = 28; +const HANDLE_OVERLAP = 2; +const RESIZE_CLEARANCE = 4; +// Grace period before hiding: crossing an arrow's 10px hit corridor (every +// task with an outgoing dependency has one at its right edge) fires itemout +// mid-travel; an instant hide would make the handle ungrabbable there. +const HIDE_GRACE_MS = 250; + +/** + * Client-side link validation from already-loaded data. Pure and exported + * for DOM-free tests. Returns a user-facing message, or null when the link + * is allowed. Cycles beyond these direct checks are rejected server-side + * (project.task._check_no_cyclic_dependencies). + * + * @param {Object} args + * @param {Number} args.predecessorId + * @param {Number} args.successorId + * @param {Function} args.getRecord id -> loaded record (or undefined) + * @param {String} args.depField m2m "blocked by" field name + * @param {Function} [args.canLink] record -> Boolean (feature gating) + * @param {Function} [args.isPending] (predecessorId, successorId) -> Boolean + * @returns {String|null} + */ +export function validateLink({ + predecessorId, + successorId, + getRecord, + depField, + canLink, + isPending, +}) { + if (predecessorId === successorId) { + return _t("A task cannot depend on itself."); + } + const successor = getRecord(successorId); + const predecessor = getRecord(predecessorId); + if (!successor || !predecessor) { + return _t("Unknown task."); + } + if (canLink && (!canLink(predecessor) || !canLink(successor))) { + return _t("Task dependencies are disabled for this project."); + } + if (isPending && isPending(predecessorId, successorId)) { + return _t("This dependency is already being saved."); + } + const successorDeps = successor[depField] || []; + if (successorDeps.includes(predecessorId)) { + return _t( + '"%s" is already blocked by "%s".', + successor.display_name, + predecessor.display_name + ); + } + const predecessorDeps = predecessor[depField] || []; + if (predecessorDeps.includes(successorId)) { + return _t( + '"%s" is already blocked by "%s" — the reverse link would conflict.', + predecessor.display_name, + successor.display_name + ); + } + return null; +} + +/** + * Framework-free drag state machine for creating dependencies. + * + * A hover-born handle lives in an overlay OUTSIDE the vis item DOM, so vis's + * Hammer instances (which only see events bubbling from the pointerdown + * target) never observe the gesture: no pan, no item drag, no select. The + * collision rule is selection-aware (see the geometry constants): on + * selected bars the link zone starts past vis's resize zone so resize stays + * fully grabbable even on 1-day pills; on unselected bars (no resize + * handles) the link zone overlaps the pill edge by 2px for gap-free travel. + * + * States: IDLE -> HANDLE_SHOWN -> DRAGGING -> IDLE. + */ +export class DependencyLinkDragger { + /** + * @param {Object} deps + * @param {Object} deps.timeline vis.Timeline instance + * @param {SVGElement} deps.canvasSvg arrow overlay SVG (cleared on redraws) + * @param {HTMLElement} deps.overlayEl handle overlay div + * @param {HTMLElement} deps.rootEl view root (gets o_tlg_linking) + * @param {String} deps.depField + * @param {Function} deps.getRecord id -> loaded record + * @param {Function} deps.canLink record -> Boolean + * @param {Function} deps.isPending (predId, succId) -> Boolean + * @param {Function} deps.onCreateDependency ({predecessorId, successorId}) + * @param {Function} deps.notify (message, type) + */ + constructor(deps) { + Object.assign(this, deps); + this.state = "IDLE"; + this.hoverId = null; + this.sourceId = null; + this.candidate = null; + this.ghostEl = null; + this.handleEl = document.createElement("div"); + this.handleEl.className = "o_tlg_handle"; + this.handleEl.style.display = "none"; + this.handleEl.title = _t("Drag to another task to create a dependency"); + this.handleEl.setAttribute("role", "button"); + this.handleEl.setAttribute( + "aria-label", + _t("Create a dependency from this task") + ); + this.handleEl.addEventListener("pointerdown", (ev) => + this._onHandlePointerDown(ev) + ); + this.handleEl.addEventListener("pointerenter", () => this._cancelHide()); + this.handleEl.addEventListener("pointerleave", () => { + if (this.state === "HANDLE_SHOWN") { + this._scheduleHide(); + } + }); + this.overlayEl.appendChild(this.handleEl); + this._onPointerMove = this._onPointerMove.bind(this); + this._onPointerUp = this._onPointerUp.bind(this); + this._onPointerCancel = () => this._cancel(); + this._onKeyDown = (ev) => { + if (ev.key === "Escape") { + ev.stopPropagation(); + this._cancel(); + } + }; + this._onWindowBlur = () => this._cancel(); + this._rafPending = false; + this._lastPointerEvent = null; + this._hideTimer = null; + } + + destroy() { + this._cancel(); + this.handleEl.remove(); + } + + // ------------------------------------------------------------------ + // Hover handle + // ------------------------------------------------------------------ + + onItemOver(id) { + if (this.state === "DRAGGING") { + return; + } + this._cancelHide(); + const item = this.timeline.itemSet?.items?.[id]; + const record = Number.isInteger(id) ? this.getRecord(id) : null; + if ( + !item || + !record || + !item.displayed || + !item.dom?.box?.isConnected || + !this.canLink(record) + ) { + this._hideHandle(); + return; + } + this.hoverId = id; + this.state = "HANDLE_SHOWN"; + this._positionHandle(item); + } + + onItemOut() { + if (this.state !== "HANDLE_SHOWN") { + return; + } + // Moving from the bar onto the handle fires itemout; the handle's + // own pointerenter cancels the deferred hide. + if (!this.handleEl.matches(":hover")) { + this._scheduleHide(); + } + } + + _scheduleHide() { + this._cancelHide(); + this._hideTimer = window.setTimeout(() => { + this._hideTimer = null; + this._hideHandle(); + }, HIDE_GRACE_MS); + } + + _cancelHide() { + if (this._hideTimer !== null) { + window.clearTimeout(this._hideTimer); + this._hideTimer = null; + } + } + + onTimelineChanged() { + if (this.state === "HANDLE_SHOWN") { + const item = this.timeline.itemSet?.items?.[this.hoverId]; + if (item?.displayed && item.dom?.box?.isConnected) { + this._positionHandle(item); + } else { + this._hideHandle(); + } + } else if (this.state === "DRAGGING") { + // The canvas is wiped on every redraw: re-attach the ghost and + // recompute it against the (possibly moved) source bar. + if (this.ghostEl && !this.ghostEl.isConnected) { + this.canvasSvg.appendChild(this.ghostEl); + } + if (this._lastPointerEvent) { + this._updateDrag(this._lastPointerEvent); + } + } + } + + _positionHandle(item) { + const box = item.dom.box; + const centerRect = this.timeline.dom.centerContainer.getBoundingClientRect(); + const rect = box.getBoundingClientRect(); + // Selection-aware collision rule: see the constants above. Selection + // changes redraw the timeline, so onTimelineChanged repositions the + // handle with the current state. + const offset = item.selected ? RESIZE_CLEARANCE : -HANDLE_OVERLAP; + this.handleEl.style.display = ""; + this.handleEl.style.left = `${rect.right - centerRect.left + offset}px`; + this.handleEl.style.top = `${ + rect.top - centerRect.top + rect.height / 2 - HANDLE_SIZE / 2 + }px`; + } + + _hideHandle() { + if (this.state === "DRAGGING") { + // A stale grace timer must never remove the pointer-captured + // element mid-drag. + return; + } + this._cancelHide(); + this.handleEl.style.display = "none"; + this.hoverId = null; + if (this.state === "HANDLE_SHOWN") { + this.state = "IDLE"; + } + } + + // ------------------------------------------------------------------ + // Drag + // ------------------------------------------------------------------ + + _onHandlePointerDown(ev) { + if (ev.button !== 0 || this.state !== "HANDLE_SHOWN") { + return; + } + // Suppress text selection and hide the gesture from vis's Hammer + // instances (they only see events bubbling from the target). + ev.preventDefault(); + ev.stopPropagation(); + this._cancelHide(); + this.handleEl.setPointerCapture(ev.pointerId); + this.state = "DRAGGING"; + this.sourceId = this.hoverId; + this.candidate = null; + this.rootEl.classList.add("o_tlg_linking"); + this.ghostEl = document.createElementNS(SVG_NS, "path"); + this.ghostEl.setAttribute("class", "o_tlg_ghost"); + this.ghostEl.setAttribute("fill", "none"); + this.canvasSvg.appendChild(this.ghostEl); + // Pointer capture routes move/up to the handle regardless of what + // is under the cursor. + this.handleEl.addEventListener("pointermove", this._onPointerMove); + this.handleEl.addEventListener("pointerup", this._onPointerUp); + this.handleEl.addEventListener("pointercancel", this._onPointerCancel); + document.addEventListener("keydown", this._onKeyDown, {capture: true}); + window.addEventListener("blur", this._onWindowBlur); + } + + _onPointerMove(ev) { + this._lastPointerEvent = ev; + if (this._rafPending) { + return; + } + this._rafPending = true; + window.requestAnimationFrame(() => { + this._rafPending = false; + if (this.state === "DRAGGING" && this._lastPointerEvent) { + this._updateDrag(this._lastPointerEvent); + } + }); + } + + _updateDrag(ev) { + const source = this.timeline.itemSet?.items?.[this.sourceId]; + const sourceEl = source?.dom?.box; + if (!source?.displayed || !sourceEl?.isConnected) { + this._cancel(); + return; + } + const centerRect = this.timeline.dom.centerContainer.getBoundingClientRect(); + const sourceRect = sourceEl.getBoundingClientRect(); + const d = bezierPathD( + sourceRect.right - centerRect.left, + sourceRect.top - centerRect.top + sourceRect.height / 2, + ev.clientX - centerRect.left, + ev.clientY - centerRect.top, + 36 + ); + this.ghostEl?.setAttribute("d", d); + // Hit-test the drop candidate. The handle and existing arrows are + // pointer-events: none while linking, so they stay transparent here. + const el = document.elementFromPoint(ev.clientX, ev.clientY); + let visItem = null; + try { + visItem = el && this.timeline.itemSet.itemFromElement(el); + } catch { + visItem = null; + } + const id = visItem?.id; + if ( + !Number.isInteger(id) || + id === this.sourceId || + !visItem.dom?.box?.isConnected + ) { + this._setCandidate(null); + return; + } + const error = validateLink({ + predecessorId: this.sourceId, + successorId: id, + getRecord: this.getRecord, + depField: this.depField, + canLink: this.canLink, + isPending: this.isPending, + }); + this._setCandidate({id, error, el: visItem.dom.box}); + } + + _setCandidate(candidate) { + if (this.candidate?.el && this.candidate.el !== candidate?.el) { + this.candidate.el.classList.remove("o_tlg_drop_ok", "o_tlg_drop_invalid"); + } + this.candidate = candidate; + if (candidate?.el) { + candidate.el.classList.toggle("o_tlg_drop_ok", !candidate.error); + candidate.el.classList.toggle( + "o_tlg_drop_invalid", + Boolean(candidate.error) + ); + } + } + + _onPointerUp() { + const candidate = this.candidate; + const sourceId = this.sourceId; + this._cleanup(); + if (!candidate) { + return; + } + if (candidate.error) { + this.notify(candidate.error, "warning"); + return; + } + this.onCreateDependency({ + predecessorId: sourceId, + successorId: candidate.id, + }); + } + + _cancel() { + if (this.state === "DRAGGING") { + this._cleanup(); + } + } + + _cleanup() { + this.handleEl.removeEventListener("pointermove", this._onPointerMove); + this.handleEl.removeEventListener("pointerup", this._onPointerUp); + this.handleEl.removeEventListener("pointercancel", this._onPointerCancel); + document.removeEventListener("keydown", this._onKeyDown, {capture: true}); + window.removeEventListener("blur", this._onWindowBlur); + this._setCandidate(null); + this.ghostEl?.remove(); + this.ghostEl = null; + this._lastPointerEvent = null; + this.rootEl.classList.remove("o_tlg_linking"); + this.state = "IDLE"; + this.sourceId = null; + this._hideHandle(); + } +} diff --git a/web_timeline_gantt_ux/static/src/dependency_edit/move_cascade.esm.js b/web_timeline_gantt_ux/static/src/dependency_edit/move_cascade.esm.js new file mode 100644 index 00000000000..59b1e6ea304 --- /dev/null +++ b/web_timeline_gantt_ux/static/src/dependency_edit/move_cascade.esm.js @@ -0,0 +1,97 @@ +/** @odoo-module **/ + +import {_t} from "@web/core/l10n/translation"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const HOUR_MS = 60 * 60 * 1000; + +/** + * Classify a queued vis move as a pure time-shift or a resize. A center drag + * preserves the duration exactly (vis recomputes end = start + duration from + * the pre-drag clone), so equal durations with a nonzero start delta means + * "moved in time"; anything else is an edge resize. Pure and exported for + * DOM-free tests. + * + * @param {Object} dates JS Dates (or null when a bound is absent) + * @param {Date|null} dates.oldStart + * @param {Date|null} dates.oldEnd + * @param {Date|null} dates.newStart + * @param {Date|null} dates.newEnd + * @returns {{timeMove: Boolean, deltaMs: Number}} + */ +export function classifyMove({oldStart, oldEnd, newStart, newEnd}) { + if (!oldStart || !newStart) { + return {timeMove: false, deltaMs: 0}; + } + const deltaMs = newStart.getTime() - oldStart.getTime(); + const oldDuration = oldEnd ? oldEnd.getTime() - oldStart.getTime() : 0; + const newDuration = newEnd ? newEnd.getTime() - newStart.getTime() : 0; + return {timeMove: deltaMs !== 0 && oldDuration === newDuration, deltaMs}; +} + +/** + * Downstream transitive closure over the LOADED records: which records are + * (directly or indirectly) blocked by the moved ones, and by how much they + * would shift. Records outside the current search domain are not loaded and + * therefore cannot be shifted — the dialog copy says "in this view" for that + * reason. First-reached root wins when several moved records share a + * dependent; moved records themselves never appear in the result; records + * without a start date are traversed (their successors still shift) but not + * shifted themselves. Cycle-safe via the visited set. Pure and exported for + * DOM-free tests. + * + * @param {Object[]} records loaded records + * @param {String} depField m2m "blocked by" field name + * @param {String} dateStartField record field marking a scheduled record + * @param {Object[]} moves [{id, deltaMs}] classified time-moves + * @returns {Map} record id -> deltaMs to apply + */ +export function collectDependentShifts(records, depField, dateStartField, moves) { + const successorsOf = new Map(); + for (const rec of records) { + for (const predId of rec[depField] || []) { + if (!successorsOf.has(predId)) { + successorsOf.set(predId, []); + } + successorsOf.get(predId).push(rec); + } + } + const movedIds = new Set(moves.map((move) => move.id)); + const visited = new Set(movedIds); + const shifts = new Map(); + for (const {id, deltaMs} of moves) { + const stack = [id]; + while (stack.length) { + const current = stack.pop(); + for (const rec of successorsOf.get(current) || []) { + if (visited.has(rec.id)) { + continue; + } + visited.add(rec.id); + if (rec[dateStartField]) { + shifts.set(rec.id, deltaMs); + } + stack.push(rec.id); + } + } + } + return shifts; +} + +/** + * Human label for a shift: whole days when the delta is day-aligned (the + * usual case with day-snapped drags), hours otherwise. + * + * @param {Number} deltaMs signed shift in milliseconds + * @returns {String} e.g. "+2 days", "-1 day", "+5 hours" + */ +export function formatDeltaLabel(deltaMs) { + const sign = deltaMs < 0 ? "-" : "+"; + const abs = Math.abs(deltaMs); + if (abs % DAY_MS === 0) { + const days = abs / DAY_MS; + return days === 1 ? _t("%s1 day", sign) : _t("%s%s days", sign, days); + } + const hours = Math.round((abs / HOUR_MS) * 10) / 10; + return hours === 1 ? _t("%s1 hour", sign) : _t("%s%s hours", sign, hours); +} diff --git a/web_timeline_gantt_ux/static/src/dependency_edit/move_cascade_dialog.xml b/web_timeline_gantt_ux/static/src/dependency_edit/move_cascade_dialog.xml new file mode 100644 index 00000000000..5d50715e9df --- /dev/null +++ b/web_timeline_gantt_ux/static/src/dependency_edit/move_cascade_dialog.xml @@ -0,0 +1,22 @@ + + + + +

+ + + + + +

+
+
diff --git a/web_timeline_gantt_ux/static/src/dependency_edit/move_cascade_patch.esm.js b/web_timeline_gantt_ux/static/src/dependency_edit/move_cascade_patch.esm.js new file mode 100644 index 00000000000..753ed546ec5 --- /dev/null +++ b/web_timeline_gantt_ux/static/src/dependency_edit/move_cascade_patch.esm.js @@ -0,0 +1,213 @@ +/** @odoo-module **/ + +import {Component} from "@odoo/owl"; +import {Dialog} from "@web/core/dialog/dialog"; +import {TimelineController} from "@web_timeline/views/timeline/timeline_controller.esm"; +import {_t} from "@web/core/l10n/translation"; +import {patch} from "@web/core/utils/patch"; +import { + classifyMove, + collectDependentShifts, + formatDeltaLabel, +} from "@web_timeline_gantt_ux/dependency_edit/move_cascade.esm"; + +/** + * Three-way choice for a time-move whose task has dependents: shift the + * dependents along, move only the dragged task, or cancel the move entirely + * (Esc / X count as cancel — the bar snaps back). + */ +export class MoveCascadeDialog extends Component { + static template = "web_timeline_gantt_ux.MoveCascadeDialog"; + static components = {Dialog}; + static props = { + close: Function, + title: String, + body: String, + onChoice: Function, + }; + + choose(choice) { + this.props.onChoice(choice); + this.props.close(); + } +} + +patch(TimelineController.prototype, { + /** + * Gantt mode replaces the stock drain: when a queued move is a pure + * time-shift and other loaded records are (transitively) blocked by it, + * ask whether the dependents shift along. COUPLING: mirrors the stock + * internalMove contract — drain this.moveQueue, write, invoke each + * entry's vis callback (truthy applies the drop, null snaps the bar + * back), then reload once. + * + * @override + */ + async internalMove() { + if (!this.props.modelParams.gantt_ux || !this.model.params.dependency_arrow) { + return super.internalMove(...arguments); + } + // Serialize drains: a second drag while the dialog is open must wait + // for the decision, not interleave writes or stack dialogs. The + // stored chain swallows rejections so one failed drain cannot poison + // the next; the returned promise still rejects like the stock path. + const run = (this._tlgMoveChain || Promise.resolve()).then(() => + this._tlgGanttInternalMove() + ); + this._tlgMoveChain = run.catch(() => { + // Chain keep-alive only; the error surfaces via `run`. + }); + return run; + }, + + async _tlgGanttInternalMove() { + const queue = this.moveQueue.slice(); + this.moveQueue = []; + if (!queue.length) { + return; + } + const depField = this.model.params.dependency_arrow; + const records = this.model.data || []; + const byId = new Map(records.map((rec) => [rec.id, rec])); + // Classify each queued entry in the same date frame vis uses + // (model.parseDate mirrors what _event_data_transform fed vis). + const moves = []; + for (const entry of queue) { + const rec = entry.item.evt || {}; + const oldStart = rec[this.date_start] + ? this.model + .parseDate( + this.model.fields[this.date_start], + rec[this.date_start] + ) + .toJSDate() + : null; + const oldEnd = + this.date_stop && rec[this.date_stop] + ? this.model + .parseDate( + this.model.fields[this.date_stop], + rec[this.date_stop] + ) + .toJSDate() + : null; + const {timeMove, deltaMs} = classifyMove({ + oldStart, + oldEnd, + newStart: entry.item.start || null, + newEnd: entry.item.end || null, + }); + if (timeMove) { + moves.push({id: entry.id, deltaMs}); + } + } + const shifts = moves.length + ? collectDependentShifts(records, depField, this.date_start, moves) + : new Map(); + let choice = "single"; + if (shifts.size) { + choice = await new Promise((resolve) => { + this.dialogService.add( + MoveCascadeDialog, + { + title: _t("Shift dependent tasks?"), + body: this._tlgCascadeBody(moves, shifts), + onChoice: resolve, + }, + {onClose: () => resolve("abort")} + ); + }); + } + if (choice === "abort") { + for (const entry of queue) { + entry.callback(null); + } + return; + } + const pendingRevert = queue.slice(); + try { + for (const entry of queue) { + await this.model.write_completed(entry.id, entry.data); + entry.callback(entry.item); + pendingRevert.shift(); + } + if (choice === "cascade") { + for (const [id, deltaMs] of shifts) { + await this.model.write_completed( + id, + this._tlgShiftedDates(byId.get(id), deltaMs) + ); + } + } + } catch (error) { + // Stock internalMove has no error path at all (bars stay at the + // drop position with nothing written). Snap back what was not + // applied, resync from the server, and surface user-facing + // rejections as a toast; crashes still crash. + for (const entry of pendingRevert) { + entry.callback(null); + } + await this.model.load(this.getSearchProps()); + this.render(); + const exceptionName = error?.exceptionName || error?.data?.name || ""; + const isUserFacing = [ + "odoo.exceptions.ValidationError", + "odoo.exceptions.UserError", + ].includes(exceptionName); + if (!isUserFacing) { + throw error; + } + this.tlgNotification.add(error.data?.message || error.message, { + title: _t("Move not saved"), + type: "danger", + }); + return; + } + // The gantt renderer suppresses the stock auto-fit, so this reload + // does not move the window. + await this.model.load(this.getSearchProps()); + this.render(); + }, + + _tlgCascadeBody(moves, shifts) { + const count = shifts.size; + if (moves.length === 1) { + const record = (this.model.data || []).find( + (rec) => rec.id === moves[0].id + ); + return _t( + '"%s" moves by %s. %s dependent task(s) in this view follow it. Shift them as well?', + record?.display_name || `#${moves[0].id}`, + formatDeltaLabel(moves[0].deltaMs), + count + ); + } + return _t( + "%s dependent task(s) in this view follow the moved tasks. Shift them as well?", + count + ); + }, + + /** + * Shift a record's date fields by deltaMs, preserving its time-of-day + * and duration. Serialization mirrors the stock move write. + * + * @param {Object} record loaded record + * @param {Number} deltaMs signed shift + * @returns {Object} write vals + */ + _tlgShiftedDates(record, deltaMs) { + const vals = {}; + const start = this.model + .parseDate(this.model.fields[this.date_start], record[this.date_start]) + .plus({milliseconds: deltaMs}); + vals[this.date_start] = this.model.serializeDate(this.date_start, start); + if (this.date_stop && record[this.date_stop]) { + const end = this.model + .parseDate(this.model.fields[this.date_stop], record[this.date_stop]) + .plus({milliseconds: deltaMs}); + vals[this.date_stop] = this.model.serializeDate(this.date_stop, end); + } + return vals; + }, +}); diff --git a/web_timeline_gantt_ux/static/src/dependency_edit/renderer_dep_patch.esm.js b/web_timeline_gantt_ux/static/src/dependency_edit/renderer_dep_patch.esm.js new file mode 100644 index 00000000000..645e5711a61 --- /dev/null +++ b/web_timeline_gantt_ux/static/src/dependency_edit/renderer_dep_patch.esm.js @@ -0,0 +1,172 @@ +/** @odoo-module **/ + +import {onWillUnmount} from "@odoo/owl"; +import {TimelineRenderer} from "@web_timeline/views/timeline/timeline_renderer.esm"; +import {DependencyLinkDragger} from "@web_timeline_gantt_ux/dependency_edit/dependency_link_dragger.esm"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; + +const NEW_HIGHLIGHT_MS = 1500; + +patch(TimelineRenderer.prototype, { + setup() { + super.setup(...arguments); + this.tlgNotification = useService("notification"); + // In-flight dependency writes, re-drawn as dashed pending curves on + // every canvas pass (the canvas is wiped on each vis "changed"). + this.pendingDeps = new Map(); + // Recently created pairs, highlighted for a short while. + this.newDeps = new Set(); + onWillUnmount(() => this.linkDragger?.destroy()); + }, + + get depEditEnabled() { + return Boolean(this.ganttUx && this.dependency_arrow && this.model.canEdit); + }, + + /** + * Feature gating: when the model exposes the standard Odoo + * allow_task_dependencies related field, respect it per record — a link + * created on a gated project would be invisible in the task form. + * + * @param {Object} record loaded record + * @returns {Boolean} + */ + _tlgCanLink(record) { + if (!record) { + return false; + } + if ("allow_task_dependencies" in (this.model.fields || {})) { + return Boolean(record.allow_task_dependencies); + } + return true; + }, + + init_timeline() { + super.init_timeline(...arguments); + if (!this.depEditEnabled) { + return; + } + this.rootRef.el?.classList.add("o_tlg_dep_editable"); + const overlay = document.createElement("div"); + overlay.className = "o_tlg_overlay"; + this.timeline.dom.centerContainer.appendChild(overlay); + this.linkDragger = new DependencyLinkDragger({ + timeline: this.timeline, + canvasSvg: this.canvas_ref, + overlayEl: overlay, + rootEl: this.rootRef.el, + depField: this.dependency_arrow, + getRecord: (id) => this.timeline.itemsData?.get(id)?.evt, + canLink: (record) => this._tlgCanLink(record), + isPending: (predecessorId, successorId) => + this.pendingDeps.has(`${predecessorId}->${successorId}`), + onCreateDependency: ({predecessorId, successorId}) => + this._ganttCreateDependency(predecessorId, successorId), + notify: (message, type) => this.tlgNotification.add(message, {type}), + }); + this.timeline.on("itemover", (e) => this.linkDragger.onItemOver(e.item)); + this.timeline.on("itemout", () => this.linkDragger.onItemOut()); + this.timeline.on("changed", () => this.linkDragger.onTimelineChanged()); + // Arrow removal: delegated on the SVG root (events bubble from the + // strokes even though the root itself is pointer-events: none). + this.canvas_ref.addEventListener("click", (ev) => this._tlgOnArrowActivate(ev)); + this.canvas_ref.addEventListener("keydown", (ev) => { + if (ev.key === "Delete") { + this._tlgOnArrowActivate(ev); + } + }); + }, + + _tlgOnArrowActivate(ev) { + const hit = ev.target.closest?.("path.o_tlg_dep_hit"); + if (!hit || !this.props.onRemoveDependency) { + return; + } + ev.stopPropagation(); + this.props.onRemoveDependency({ + predecessorId: Number(hit.dataset.predecessorId), + successorId: Number(hit.dataset.successorId), + }); + }, + + /** + * Wrap the controller write with the pending/highlight lifecycle. The + * pair is locked while in flight (validateLink checks isPending). + * + * @param {Number} predecessorId + * @param {Number} successorId + */ + async _ganttCreateDependency(predecessorId, successorId) { + if (!this.props.onCreateDependency) { + return; + } + const key = `${predecessorId}->${successorId}`; + if (this.pendingDeps.has(key)) { + return; + } + this.pendingDeps.set(key, {predecessorId, successorId}); + this.draw_canvas(); + let created = false; + try { + created = await this.props.onCreateDependency({ + predecessorId, + successorId, + }); + } finally { + this.pendingDeps.delete(key); + } + if (created) { + this.newDeps.add(key); + setTimeout(() => { + this.newDeps.delete(key); + this.canvas_ref + ?.querySelector( + `path.o_tlg_dep_arrow.o_tlg_new[data-predecessor-id="${predecessorId}"][data-successor-id="${successorId}"]` + ) + ?.classList.remove("o_tlg_new"); + }, NEW_HIGHLIGHT_MS); + } + this.draw_canvas(); + }, + + /** + * Called by the layout module at the end of each draw_dependencies pass. + */ + _ganttDrawPendingDeps() { + const items = this.timeline?.itemSet?.items; + if (!items) { + return; + } + for (const {predecessorId, successorId} of this.pendingDeps.values()) { + const from = items[predecessorId]; + const to = items[successorId]; + const fromEl = from?.dom?.box || from?.dom?.point; + const toEl = to?.dom?.box || to?.dom?.point; + if (!fromEl?.isConnected || !toEl?.isConnected) { + continue; + } + this.canvas.drawDependencyCurve(fromEl, toEl, { + predecessorId, + successorId, + pending: true, + }); + } + for (const key of this.newDeps) { + const [predecessorId, successorId] = key.split("->"); + this.canvas_ref + ?.querySelector( + `path.o_tlg_dep_arrow[data-predecessor-id="${predecessorId}"][data-successor-id="${successorId}"]` + ) + ?.classList.add("o_tlg_new"); + } + }, +}); + +TimelineRenderer.props = { + ...TimelineRenderer.props, + // Optional: stock timeline views without the patched controller chain + // must stay valid under OWL dev-mode prop validation. + onCreateDependency: {type: Function, optional: true}, + onRemoveDependency: {type: Function, optional: true}, +}; diff --git a/web_timeline_gantt_ux/static/src/gantt_layout/canvas_defs.xml b/web_timeline_gantt_ux/static/src/gantt_layout/canvas_defs.xml new file mode 100644 index 00000000000..fb5b40981e5 --- /dev/null +++ b/web_timeline_gantt_ux/static/src/gantt_layout/canvas_defs.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/web_timeline_gantt_ux/static/src/gantt_layout/canvas_patch.esm.js b/web_timeline_gantt_ux/static/src/gantt_layout/canvas_patch.esm.js new file mode 100644 index 00000000000..f9834b35204 --- /dev/null +++ b/web_timeline_gantt_ux/static/src/gantt_layout/canvas_patch.esm.js @@ -0,0 +1,91 @@ +/** @odoo-module **/ + +import {TimelineCanvas} from "@web_timeline/views/timeline/timeline_canvas.esm"; +import {_t} from "@web/core/l10n/translation"; +import {bezierPathD} from "@web_timeline_gantt_ux/core/bezier.esm"; +import {patch} from "@web/core/utils/patch"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const ROW_HEIGHT = 36; + +patch(TimelineCanvas.prototype, { + /** + * Bounding box of an element relative to the .vis-center panel (the + * same coordinate space the stock draw_line uses). + * + * @param {HTMLElement} el + * @returns {{x: Number, y: Number, w: Number, h: Number}} + */ + _tlgRelRect(el) { + const pos = el.getBoundingClientRect(); + const parent = el.closest(".vis-center")?.getBoundingClientRect(); + return { + x: pos.left - (parent?.left || 0), + y: pos.top - (parent?.top || 0), + w: pos.width, + h: pos.height, + }; + }, + + /** + * Draw one dependency as a smooth Bézier curve from the predecessor's + * right edge to the successor's left edge. Two stacked paths per arrow: + * a transparent 10px hit corridor first (interaction/a11y target), the + * visible curve second (so `.o_tlg_dep_hit:hover + .o_tlg_dep_arrow` + * works with pure CSS). Both are wiped by clear() on every redraw — + * callers must not hold element references. + * + * @param {HTMLElement} fromEl predecessor .vis-item box + * @param {HTMLElement} toEl successor .vis-item box + * @param {Object} options + * @param {Number} options.predecessorId + * @param {Number} options.successorId + * @param {String} [options.label] canonical a11y/tooltip sentence + * @param {Boolean} [options.interactive] add the hit corridor + a11y + * @param {Boolean} [options.pending] render as in-flight (dashed) + * @returns {SVGPathElement} the visible path + */ + drawDependencyCurve(fromEl, toEl, options) { + const {predecessorId, successorId, label, interactive, pending} = options; + const from = this._tlgRelRect(fromEl); + const to = this._tlgRelRect(toEl); + const d = bezierPathD( + from.x + from.w, + from.y + from.h / 2, + to.x, + to.y + to.h / 2, + ROW_HEIGHT + ); + if (interactive && !pending) { + const hit = document.createElementNS(SVG_NS, "path"); + hit.setAttribute("d", d); + hit.setAttribute("class", "o_tlg_dep_hit"); + hit.setAttribute("stroke", "transparent"); + hit.setAttribute("stroke-width", "10"); + hit.setAttribute("fill", "none"); + hit.setAttribute("tabindex", "0"); + hit.setAttribute("role", "button"); + if (label) { + hit.setAttribute( + "aria-label", + `${label}. ${_t("Press Delete to remove.")}` + ); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = `${label} — ${_t("click to remove")}`; + hit.appendChild(title); + } + hit.dataset.predecessorId = predecessorId; + hit.dataset.successorId = successorId; + this.canvas_ref.appendChild(hit); + } + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("d", d); + path.setAttribute("class", `o_tlg_dep_arrow${pending ? " o_tlg_pending" : ""}`); + path.setAttribute("fill", "none"); + path.setAttribute("marker-end", "url(#o_tlg_arrowhead)"); + path.dataset.predecessorId = predecessorId; + path.dataset.successorId = successorId; + this.canvas_ref.appendChild(path); + return path; + }, +}); diff --git a/web_timeline_gantt_ux/static/src/gantt_layout/controller_layout_patch.esm.js b/web_timeline_gantt_ux/static/src/gantt_layout/controller_layout_patch.esm.js new file mode 100644 index 00000000000..a32f388ff33 --- /dev/null +++ b/web_timeline_gantt_ux/static/src/gantt_layout/controller_layout_patch.esm.js @@ -0,0 +1,70 @@ +/** @odoo-module **/ + +import {TimelineController} from "@web_timeline/views/timeline/timeline_controller.esm"; +import {patch} from "@web/core/utils/patch"; + +/** + * Translate a namespaced gantt group id back to a group-by default value + * for record creation. Pure and exported for DOM-free tests. + * + * @param {String} group namespaced group id ("grp_" / "rec_" / other) + * @param {Object[]} records loaded records (rec_ lookup) + * @param {String} groupedField group-by field name + * @returns {Number} group-by value id, or -1 (unassigned) + */ +export function remapAddGroup(group, records, groupedField) { + let match = group.match(/^grp_(\d+)$/); + if (match) { + return parseInt(match[1], 10); + } + if ((match = group.match(/^rec_(\d+)$/))) { + const record = (records || []).find((rec) => rec.id === parseInt(match[1], 10)); + const value = record?.[groupedField]; + return Array.isArray(value) ? value[0] : -1; + } + return -1; +} + +patch(TimelineController.prototype, { + /** + * In gantt mode item.group is a namespaced row id ("rec_"), never a + * valid group-by value: writing it would send e.g. project_id: "rec_42" + * on every horizontal drag. + * + * COUPLING: this reaches into the stock controller's private moveQueue + * right after super pushed to it (the push is synchronous; the debounced + * flush happens later). Locked by the controller_move regression test — + * if an OCA bump reshapes the queue, that test fails loudly instead of + * every bar drag failing in production. + * + * @override + */ + _onMove(item, callback) { + super._onMove(item, callback); + if (this.props.modelParams.gantt_ux) { + const groupedField = this.model.last_group_bys[0]; + const queued = this.moveQueue[this.moveQueue.length - 1]; + if (queued?.data && groupedField in queued.data) { + delete queued.data[groupedField]; + } + } + }, + + /** + * Double-tap-to-create lands on a namespaced group; translate it back to + * a real group-by default before the stock handler builds the creation + * context. + * + * @override + */ + _onAdd(item, callback) { + if (this.props.modelParams.gantt_ux && typeof item.group === "string") { + item.group = remapAddGroup( + item.group, + this.model.data, + this.model.last_group_bys[0] + ); + } + return super._onAdd(item, callback); + }, +}); diff --git a/web_timeline_gantt_ux/static/src/gantt_layout/gantt_theme.scss b/web_timeline_gantt_ux/static/src/gantt_layout/gantt_theme.scss new file mode 100644 index 00000000000..6284044999e --- /dev/null +++ b/web_timeline_gantt_ux/static/src/gantt_layout/gantt_theme.scss @@ -0,0 +1,295 @@ +// Enterprise-Gantt-style light theme for web_timeline views that opt in via +// gantt_ux="true". Scoped under .o_timeline_gantt (set by the renderer patch) +// so every other timeline view keeps the stock look. +// +// All colors and sizes are --o-tlg-* design tokens defined once below; every +// rule consumes var(...). No raw hex outside this block (the arrowhead marker +// fill in canvas_defs.xml mirrors --o-tlg-arrow). + +.o_timeline_gantt { + --o-tlg-text: #374151; + --o-tlg-muted: #8f95a3; + --o-tlg-border: #e7e9ed; + --o-tlg-grid: #f1f2f4; + --o-tlg-band: #f8f7fa; + --o-tlg-weekend: #f5f6f8; + --o-tlg-accent: #714b67; + --o-tlg-today: rgba(113, 75, 103, 0.12); + --o-tlg-arrow: #8f95a3; + --o-tlg-bar: #f3c6cf; + --o-tlg-bar-border: #b0798d; + --o-tlg-bar-text: #574149; + --o-tlg-unassigned-border: #8f95a3; + --o-tlg-row-h: 36px; + --o-tlg-bar-h: 22px; + --o-tlg-danger: #d23f3f; + --o-tlg-row-hover: rgba(113, 75, 103, 0.05); + // Sidebar only carries project bands (task names live on the bars). + --o-tlg-sidebar-w: 200px; + + // Motion policy: color/opacity/outline only, 120ms ease-out. Geometry is + // never animated (the timeline repositions elements on every redraw). + + .vis-timeline { + border: none; + background: #fff; + font-size: 13px; + color: var(--o-tlg-text); + } + + // ── Sidebar ───────────────────────────────────────────────────────── + .vis-panel.vis-left { + border: none; + border-right: 1px solid var(--o-tlg-border); + width: var(--o-tlg-sidebar-w); + min-width: var(--o-tlg-sidebar-w); + } + .vis-labelset .vis-label { + display: flex; + align-items: center; + min-height: var(--o-tlg-row-h); + border-bottom: 1px solid var(--o-tlg-border); + color: var(--o-tlg-text); + + .vis-inner { + padding: 0 8px; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + // Project header bands (parent groups). + .vis-labelset .vis-label.o_tlg_project_row { + background: var(--o-tlg-band); + font-weight: 600; + + &.vis-nesting-group:before { + color: var(--o-tlg-accent); + font-size: 9px; + width: 18px; + text-align: center; + flex: none; + } + &.vis-nesting-group.expanded:before { + content: "\25BE"; // ▾ + } + &.vis-nesting-group.collapsed:before { + content: "\25B8"; // ▸ + } + .o_tlg_count { + color: var(--o-tlg-muted); + font-weight: 400; + margin-left: 6px; + } + } + // Task rows are blank alignment tracks (names live on the bars); kill + // vis's injected level darkening, keep the row-trace hover + click-open. + .vis-labelset .vis-label.o_tlg_task_row { + background: #fff; + cursor: pointer; + transition: background-color 120ms ease-out; + + &.vis-nested-group.vis-group-level-1 { + background: #fff; + } + &:hover { + background: var(--o-tlg-row-hover); + } + } + + // ── Chart rows ────────────────────────────────────────────────────── + .vis-foreground .vis-group { + border-bottom: 1px solid var(--o-tlg-border); + min-height: var(--o-tlg-row-h); + } + .vis-foreground .vis-group.o_tlg_project_row { + background: var(--o-tlg-band); + } + .vis-foreground .vis-group.o_tlg_task_row:hover { + background-color: var(--o-tlg-row-hover); + } + + // ── Bars (pills) ──────────────────────────────────────────────────── + .vis-item.vis-range { + height: var(--o-tlg-bar-h); + border: 1px solid var(--o-tlg-bar-border); + border-radius: calc(var(--o-tlg-bar-h) / 2); + // Record colors arrive as --o-tlg-item-color (model patch); the + // stock theme's linen `:hover !important` rule is beaten by keeping + // the same declaration on both states and darkening via filter. + background-color: var(--o-tlg-item-color, var(--o-tlg-bar)) !important; + color: var(--o-tlg-bar-text); + font-size: 12px; + line-height: calc(var(--o-tlg-bar-h) - 2px); + transition: + filter 120ms ease-out, + outline-color 120ms ease-out; + + &:hover { + background-color: var(--o-tlg-item-color, var(--o-tlg-bar)) !important; + filter: brightness(0.96); + cursor: pointer !important; + } + &.o_tlg_colored { + border-color: rgba(0, 0, 0, 0.44); + border-color: color-mix(in srgb, var(--o-tlg-item-color) 65%, black); + } + &.vis-selected { + outline: 2px solid var(--o-tlg-accent); + outline-offset: 1px; + border-color: var(--o-tlg-bar-border); + } + &.vis-editable.vis-selected { + background-color: var(--o-tlg-item-color, var(--o-tlg-bar)) !important; + } + // Label overflow (Enterprise behavior): a non-hidden frame flips + // vis's RangeItem into overflow mode — the label renders past the + // pill edge instead of being clipped, and sticks to the left window + // edge while panning. The pill itself keeps its date-range width. + .vis-item-overflow { + overflow: visible; + } + .vis-item-content { + padding: 0 10px !important; + height: var(--o-tlg-bar-h); + } + .vis-drag-left, + .vis-drag-right { + cursor: ew-resize; + } + } + // Row-rhythm guards: the untouched project_timeline item template + // (avatars + hours) must never grow a row past 36px. No width clamp: + // the label is meant to spill past narrow pills (see above), and any + // inline-size containment here would collapse vis's shrink-to-fit + // content box to zero width, blanking the bars entirely. + .o_project_timeline_item { + height: calc(var(--o-tlg-bar-h) - 2px); + overflow: hidden; + white-space: nowrap; + + img { + max-height: 16px; + max-width: 16px; + vertical-align: middle; + } + } + + // Point items (no end date) → milestone diamond. + .vis-item.vis-point { + color: var(--o-tlg-bar-text); + } + .vis-item.vis-dot { + border: none; + border-radius: 2px; + width: 12px; + height: 12px; + transform: rotate(45deg); + background: var(--o-tlg-accent); + } + + // Synthetic items. + .vis-item.o_tlg_ghost_chip { + height: 16px; + border: 1px dashed var(--o-tlg-muted); + border-radius: 8px; + color: var(--o-tlg-muted); + font-size: 10px; + line-height: 14px; + background: repeating-linear-gradient( + 45deg, + transparent, + transparent 3px, + var(--o-tlg-grid) 3px, + var(--o-tlg-grid) 6px + ) !important; + + .vis-item-content { + padding: 0 6px !important; + } + } + .vis-item.o_tlg_rollup { + height: 10px; + border: 1px solid var(--o-tlg-bar-border); + border-radius: 5px; + background-color: var(--o-tlg-bar) !important; + opacity: 0.65; + } + + // ── Time axis ─────────────────────────────────────────────────────── + .vis-panel.vis-top { + border: none; + border-bottom: 1px solid var(--o-tlg-border); + } + .vis-time-axis .vis-text { + color: var(--o-tlg-muted); + font-size: 11px; + } + .vis-time-axis .vis-text.vis-major { + color: var(--o-tlg-text); + font-weight: 600; + font-size: 12px; + } + // Today's minor label as a filled accent pill (Enterprise cue). + .vis-time-axis .vis-text.vis-minor.vis-today { + background: var(--o-tlg-accent); + color: #fff; + border-radius: 9px; + margin: 1px 2px; + line-height: 16px; + } + .vis-grid.vis-vertical { + border-color: var(--o-tlg-grid); + } + .vis-grid.vis-saturday, + .vis-grid.vis-sunday { + background: var(--o-tlg-weekend); + } + .vis-grid.vis-today { + background: var(--o-tlg-today); + } + .vis-current-time { + background-color: var(--o-tlg-accent); + width: 2px; + } + + // ── Dependency arrows ─────────────────────────────────────────────── + // The overlay SVG root stays pointer-events: none; only the strokes are + // interactive (and only when dependency editing is enabled). + .o_tlg_dep_arrow { + stroke: var(--o-tlg-arrow); + stroke-width: 1.5; + transition: stroke 120ms ease-out; + + &.o_tlg_pending { + stroke: var(--o-tlg-accent); + stroke-dasharray: 4 3; + } + &.o_tlg_new { + stroke: var(--o-tlg-accent); + stroke-width: 2.5; + } + } + .o_tlg_dep_hit { + pointer-events: none; + outline: none; + } + &.o_tlg_dep_editable .o_tlg_dep_hit { + pointer-events: visibleStroke; + cursor: pointer; + } + .o_tlg_dep_hit:hover + .o_tlg_dep_arrow, + .o_tlg_dep_hit:focus + .o_tlg_dep_arrow { + stroke: var(--o-tlg-accent); + stroke-width: 2; + } + + @media (prefers-reduced-motion: reduce) { + .vis-item.vis-range, + .vis-labelset .vis-label.o_tlg_task_row, + .o_tlg_dep_arrow { + transition: none; + } + } +} diff --git a/web_timeline_gantt_ux/static/src/gantt_layout/model_patch.esm.js b/web_timeline_gantt_ux/static/src/gantt_layout/model_patch.esm.js new file mode 100644 index 00000000000..b55a5c1952a --- /dev/null +++ b/web_timeline_gantt_ux/static/src/gantt_layout/model_patch.esm.js @@ -0,0 +1,46 @@ +/** @odoo-module **/ + +import {TimelineModel} from "@web_timeline/views/timeline/timeline_model.esm"; +import {patch} from "@web/core/utils/patch"; + +/** + * Rewrite the stock inline item style for gantt mode. A matched colors= rule + * leaves `background-color: X;` on the item; the stock theme's + * `:hover { background-color: … !important }` rule would beat that inline + * style, so the color is carried as a CSS custom property instead and the + * theme owns background, hover and a fill-derived border via that property. + * Pure and exported for DOM-free tests. + * + * @param {String} style stock inline style ("background-color: X;") + * @returns {{style: String, colored: Boolean}} + */ +export function ganttItemStyle(style) { + const match = /background-color:\s*([^;]+);/.exec(style || ""); + if (match && match[1].trim() !== "false") { + return {style: `--o-tlg-item-color: ${match[1].trim()};`, colored: true}; + } + return {style: "", colored: false}; +} + +patch(TimelineModel.prototype, { + /** + * In gantt mode every record gets its own row: remap the vis group to a + * per-record namespaced id (the renderer builds one nested child group + * per record). Item ids stay raw record ids — dependency lookups and the + * write path rely on item.id === record.id. + * + * @override + */ + _event_data_transform(record) { + const item = super._event_data_transform(...arguments); + if (this.params.gantt_ux) { + item.group = `rec_${record.id}`; + const {style, colored} = ganttItemStyle(item.style); + item.style = style; + if (colored) { + item.className = `${item.className || ""} o_tlg_colored`.trim(); + } + } + return item; + }, +}); diff --git a/web_timeline_gantt_ux/static/src/gantt_layout/renderer_layout_patch.esm.js b/web_timeline_gantt_ux/static/src/gantt_layout/renderer_layout_patch.esm.js new file mode 100644 index 00000000000..b23ef783bb6 --- /dev/null +++ b/web_timeline_gantt_ux/static/src/gantt_layout/renderer_layout_patch.esm.js @@ -0,0 +1,374 @@ +/** @odoo-module **/ + +import {TimelineRenderer} from "@web_timeline/views/timeline/timeline_renderer.esm"; +import {_t} from "@web/core/l10n/translation"; +import {patch} from "@web/core/utils/patch"; + +const {DateTime} = luxon; + +const WINDOW_BEFORE_DAYS = 7; +const WINDOW_AFTER_DAYS = 35; +const GHOST_CHIP_DAYS = 2; + +/** + * Pure group-building for gantt mode: one collapsible parent group per + * group-by value, one child row per record. Exported for DOM-free tests. + * + * @param {Object[]} records loaded records + * @param {String} groupedField group-by field name + * @param {Set} collapsedIds parent group ids to render collapsed + * @param {String} dateStartField record field marking a scheduled record + * @returns {Object[]} vis groups (parents first, then rows) + */ +export function buildGanttGroups(records, groupedField, collapsedIds, dateStartField) { + const parents = new Map(); + const recordsByParent = new Map(); + let seq = 0; + for (const rec of records) { + let val = rec[groupedField]; + if (val && !Array.isArray(val)) { + val = false; + } + const pid = val ? `grp_${val[0]}` : "grp_unassigned"; + if (!parents.has(pid)) { + // M2o values are [id, name]; m2m values are bare id lists (first + // value only, documented limitation) with no name to show. + const label = + val && typeof val[1] === "string" ? val[1] : val ? `#${val[0]}` : ""; + parents.set(pid, { + id: pid, + name: val ? label : _t("Unassigned"), + order: val ? ++seq : -1, + nestedGroups: [], + showNested: !collapsedIds.has(pid), + className: "o_tlg_project_row", + }); + recordsByParent.set(pid, []); + } + recordsByParent.get(pid).push(rec); + } + // The fetch is ordered by the group-by field ONLY — within a parent the + // database returns ties in arbitrary, plan-dependent order, so any + // reload (a bar drag, a dependency write) could shuffle rows. Impose a + // stable in-parent order instead of trusting fetch order. + const rows = []; + for (const [pid, recs] of recordsByParent) { + recs.sort((a, b) => a.id - b.id); + const parent = parents.get(pid); + for (const rec of recs) { + parent.nestedGroups.push(`rec_${rec.id}`); + const unscheduled = !rec[dateStartField]; + rows.push({ + id: `rec_${rec.id}`, + // Task names live on the bars (the item template overflows + // the pill), not in the sidebar — rows are blank alignment + // tracks. + content: "", + title: rec.display_name || `#${rec.id}`, + order: rows.length, + // Always set: vis styles unknown levels with a red border. + treeLevel: 1, + className: `o_tlg_task_row${unscheduled ? " o_tlg_unscheduled" : ""}`, + }); + } + } + for (const parent of parents.values()) { + parent.content = `${parent.name} (${parent.nestedGroups.length})`; + delete parent.name; + } + return [...parents.values(), ...rows]; +} + +patch(TimelineRenderer.prototype, { + get ganttUx() { + return Boolean(this.params.gantt_ux); + }, + + // ------------------------------------------------------------------ + // Collapse persistence (session: harvested from the live timeline; + // across visits: localStorage keyed by model + group-by field) + // ------------------------------------------------------------------ + + _ganttStorageKey() { + return `o_tlg_collapsed:${this.model.model_name}:${this.model.last_group_bys[0]}`; + }, + + _ganttCollapsedIds() { + const groups = this.timeline?.itemSet?.groups; + if (groups && Object.keys(groups).length) { + const collapsed = new Set(); + for (const [gid, group] of Object.entries(groups)) { + if (group.nestedGroups?.length && group.showNested === false) { + collapsed.add(gid); + } + } + return collapsed; + } + try { + const raw = window.localStorage.getItem(this._ganttStorageKey()); + return new Set(raw ? JSON.parse(raw) : []); + } catch { + return new Set(); + } + }, + + _ganttSaveCollapsed() { + const collapsed = this._ganttCollapsedIds(); + try { + window.localStorage.setItem( + this._ganttStorageKey(), + JSON.stringify([...collapsed]) + ); + } catch { + // Storage unavailable (private mode/quota): session-only. + } + }, + + // ------------------------------------------------------------------ + // Groups + // ------------------------------------------------------------------ + + async split_groups(records) { + if (!this.ganttUx || this.model.last_group_bys.length === 0) { + return super.split_groups(...arguments); + } + return buildGanttGroups( + records, + this.model.last_group_bys[0], + this._ganttCollapsedIds(), + this.date_start + ); + }, + + // ------------------------------------------------------------------ + // Window ownership: the stock renderer re-fits after every data load + // and re-windows on mount, which would clobber any initial window and + // recenter the view after each edit. + // ------------------------------------------------------------------ + + async on_data_loaded(records, adjust_window) { + if (!this.ganttUx) { + return super.on_data_loaded(records, adjust_window); + } + // Best-effort vertical-scroll preservation: setGroups can nudge the + // internal scroll position (vis-internal state, hence the guards). + const scrollTop = this.timeline?.props?.scrollTop; + await super.on_data_loaded(records, false); + this._ganttSpans = this._ganttComputeSpans(records); + this._ganttSyncSyntheticItems(records); + if (typeof scrollTop === "number" && this.timeline?.props) { + this.timeline.props.scrollTop = scrollTop; + } + return undefined; + }, + + on_attach_callback() { + if (!this.ganttUx) { + return super.on_attach_callback(...arguments); + } + // Let the stock callback size the widget, then take the window over. + super.on_attach_callback(...arguments); + if (this.timeline && !this._ganttWindowSet) { + const today = DateTime.now().startOf("day"); + this.timeline.setWindow( + today.minus({days: WINDOW_BEFORE_DAYS}).toJSDate(), + today.plus({days: WINDOW_AFTER_DAYS}).toJSDate(), + {animation: false} + ); + this._ganttWindowSet = true; + } + return undefined; + }, + + // ------------------------------------------------------------------ + // Synthetic items: "unscheduled" ghost chips (generic-only path) and + // Enterprise-style rollup bands on collapsed parents. Both are + // non-editable and carry string ids, keeping them out of the + // dependency loop (which resolves numeric record ids only). + // ------------------------------------------------------------------ + + _ganttComputeSpans(records) { + const groupedField = this.model.last_group_bys[0]; + const spans = new Map(); + for (const rec of records) { + const start = rec[this.date_start]; + if (!start) { + continue; + } + const val = rec[groupedField]; + const pid = Array.isArray(val) ? `grp_${val[0]}` : "grp_unassigned"; + const item = this.timeline.itemsData.get(rec.id); + if (!item) { + continue; + } + const end = item.end || item.start; + const span = spans.get(pid); + if (!span) { + spans.set(pid, {min: item.start, max: end}); + } else { + if (item.start < span.min) { + span.min = item.start; + } + if (end > span.max) { + span.max = end; + } + } + } + return spans; + }, + + _ganttSyncSyntheticItems(records) { + const data = this.timeline.itemsData; + if (!data) { + return; + } + const stale = data + .getIds() + .filter( + (id) => + typeof id === "string" && + (id.startsWith("ghost_") || id.startsWith("rollup_")) + ); + if (stale.length) { + data.remove(stale); + } + const additions = []; + // Ghost chips for unscheduled records (no start date). + if (records) { + const windowStart = DateTime.fromJSDate( + this.timeline.getWindow().start + ).startOf("day"); + for (const rec of records) { + if (rec[this.date_start]) { + continue; + } + additions.push({ + id: `ghost_${rec.id}`, + group: `rec_${rec.id}`, + start: windowStart.toJSDate(), + end: windowStart.plus({days: GHOST_CHIP_DAYS}).toJSDate(), + // The chip is the record's only label now that sidebar + // rows are blank. + content: rec.display_name || `#${rec.id}`, + title: _t("No planned dates yet — set them in the task form"), + className: "o_tlg_ghost_chip", + editable: false, + selectable: false, + }); + } + } + // Rollup bands on collapsed parents. + const collapsed = this._ganttCollapsedIds(); + for (const pid of collapsed) { + const span = this._ganttSpans?.get(pid); + if (!span) { + continue; + } + additions.push({ + id: `rollup_${pid}`, + group: pid, + start: span.min, + end: span.max, + content: "", + className: "o_tlg_rollup", + editable: false, + selectable: false, + }); + } + if (additions.length) { + data.add(additions); + } + }, + + // ------------------------------------------------------------------ + // Timeline init / clicks + // ------------------------------------------------------------------ + + init_timeline() { + super.init_timeline(...arguments); + if (!this.ganttUx) { + return; + } + // Row-per-record: moving a bar to another row would mean + // "re-identify the record" — never write the group-by from a drag. + this.timeline.setOptions({ + editable: {...this.options.editable, updateGroup: false}, + }); + if (this.rootRef.el) { + this.rootRef.el.classList.add("o_timeline_gantt"); + } + }, + + on_timeline_click(e) { + if (!this.ganttUx) { + return super.on_timeline_click(...arguments); + } + if (e.what === "group-label" && typeof e.group === "string") { + if (e.group.startsWith("rec_")) { + // Sidebar task-name click: open the record. + this.props.onItemDoubleClick({ + item: parseInt(e.group.slice(4), 10), + }); + } else { + // Parent band: vis already toggled the collapse — persist it + // and refresh the rollup bands. + this._ganttSaveCollapsed(); + this._ganttSyncSyntheticItems(this.model.data); + } + return undefined; + } + return super.on_timeline_click(...arguments); + }, + + // ------------------------------------------------------------------ + // Dependency arrows: Bézier curves predecessor-end → successor-start + // (Enterprise semantic; the stock code draws the reverse), skipping + // items hidden by collapsed groups. Fixes the stock loop's + // return-instead-of-continue bug along the way. + // ------------------------------------------------------------------ + + draw_dependencies() { + if (!this.ganttUx) { + return super.draw_dependencies(...arguments); + } + const items = this.timeline?.itemSet?.items; + const datas = this.timeline?.itemsData; + if (!items || !datas) { + return; + } + const interactive = this.depEditEnabled === true; + for (const key of Object.keys(items)) { + const successor = items[key]; + const recordId = Number(key); + if (!Number.isInteger(recordId)) { + continue; + } + const data = datas.get(recordId); + if (!data || !data.evt) { + continue; + } + for (const predId of data.evt[this.dependency_arrow] || []) { + const predecessor = items[predId]; + if (!predecessor || !predecessor.displayed || !successor.displayed) { + continue; + } + const fromEl = predecessor.dom?.box || predecessor.dom?.point; + const toEl = successor.dom?.box || successor.dom?.point; + if (!fromEl?.isConnected || !toEl?.isConnected) { + continue; + } + const predName = datas.get(predId)?.evt?.display_name || predId; + const succName = data.evt.display_name || data.id; + this.canvas.drawDependencyCurve(fromEl, toEl, { + predecessorId: predId, + successorId: data.id, + label: _t('"%s" is blocked by "%s"', succName, predName), + interactive, + }); + } + } + // Dependency-edit module hook: pending (in-flight) arrows must be + // re-drawn on every pass because the canvas is cleared each redraw. + this._ganttDrawPendingDeps?.(); + }, +}); diff --git a/web_timeline_gantt_ux/static/tests/arch_parser.test.js b/web_timeline_gantt_ux/static/tests/arch_parser.test.js new file mode 100644 index 00000000000..f6cbf9521d2 --- /dev/null +++ b/web_timeline_gantt_ux/static/tests/arch_parser.test.js @@ -0,0 +1,70 @@ +/** @odoo-module **/ + +import {describe, expect, test} from "@odoo/hoot"; +import { + applyGanttOptions, + snapToDay, +} from "@web_timeline_gantt_ux/core/arch_parser_patch.esm"; +import {TimelineArchParser} from "@web_timeline/views/timeline/timeline_arch_parser.esm"; + +const FIELDS = { + display_name: {type: "char"}, + date_start: {type: "datetime"}, + project_id: {type: "many2one"}, +}; + +function parseArch(archString) { + const arch = new DOMParser().parseFromString( + archString, + "text/xml" + ).documentElement; + return new TimelineArchParser().parse(arch, FIELDS); +} + +describe("web_timeline_gantt_ux arch parser", () => { + test("gantt_ux attribute enables the option overrides", () => { + const archInfo = parseArch( + `` + ); + expect(archInfo.gantt_ux).toBe(true); + expect(archInfo.options.stack).toBe(false); + expect(archInfo.options.orientation.axis).toBe("top"); + expect(archInfo.options.dataAttributes).toEqual(["id"]); + expect(archInfo.options.zoomMin).toBe(3 * 24 * 60 * 60 * 1000); + expect(typeof archInfo.options.snap).toBe("function"); + // The initial window is renderer-owned, never an option. + expect(archInfo.options.start).toBe(undefined); + expect(archInfo.options.end).toBe(undefined); + }); + + test("without gantt_ux the archInfo stays stock", () => { + const archInfo = parseArch( + `` + ); + expect(archInfo.gantt_ux).toBe(false); + expect(archInfo.options.stack).toBe(true); + expect(archInfo.options.orientation.axis).toBe("both"); + expect(archInfo.options.snap).toBe(undefined); + }); + + test("explicit arch attributes win over gantt defaults", () => { + const node = new DOMParser().parseFromString( + ``, + "text/xml" + ).documentElement; + const archInfo = {options: {stack: true, margin: {item: 2}}}; + applyGanttOptions(archInfo, node); + expect(archInfo.options.stack).toBe(true); + // Margin not set in the arch: gantt default applies. + expect(archInfo.options.margin).toEqual({item: 6, axis: 8}); + }); + + test("snapToDay rounds to the nearest local midnight", () => { + const morning = new Date(2026, 6, 28, 11, 59); + const afternoon = new Date(2026, 6, 28, 12, 1); + expect(snapToDay(morning).getDate()).toBe(28); + expect(snapToDay(morning).getHours()).toBe(0); + expect(snapToDay(afternoon).getDate()).toBe(29); + expect(snapToDay(afternoon).getHours()).toBe(0); + }); +}); diff --git a/web_timeline_gantt_ux/static/tests/bezier.test.js b/web_timeline_gantt_ux/static/tests/bezier.test.js new file mode 100644 index 00000000000..66fc9d264ce --- /dev/null +++ b/web_timeline_gantt_ux/static/tests/bezier.test.js @@ -0,0 +1,32 @@ +/** @odoo-module **/ + +import {describe, expect, test} from "@odoo/hoot"; +import {bezierPathD} from "@web_timeline_gantt_ux/core/bezier.esm"; + +describe("web_timeline_gantt_ux bezier", () => { + test("forward link is a single horizontal-tangent cubic", () => { + const d = bezierPathD(100, 50, 300, 120, 36); + expect(d.startsWith("M100,50 ")).toBe(true); + expect(d.endsWith("300,120")).toBe(true); + // Exactly one cubic segment. + expect(d.split("C").length).toBe(2); + // Offset clamped to MAX (60) for a 200px gap. + expect(d).toInclude("C160,50"); + expect(d).toInclude("240,120"); + }); + + test("backward link takes the S-route through the mid line", () => { + const d = bezierPathD(300, 50, 100, 120, 36); + // Two cubic segments out-right and back-left. + expect(d.split("C").length).toBe(3); + // Mid Y between rows. + expect(d).toInclude(",85 "); + }); + + test("same-row backward link detours one row height below", () => { + const d = bezierPathD(300, 50, 290, 50, 36); + expect(d.split("C").length).toBe(3); + // MidY = y1 + rowHeight. + expect(d).toInclude(",86 "); + }); +}); diff --git a/web_timeline_gantt_ux/static/tests/controller_add.test.js b/web_timeline_gantt_ux/static/tests/controller_add.test.js new file mode 100644 index 00000000000..6a669d9b405 --- /dev/null +++ b/web_timeline_gantt_ux/static/tests/controller_add.test.js @@ -0,0 +1,26 @@ +/** @odoo-module **/ + +import {describe, expect, test} from "@odoo/hoot"; +import {remapAddGroup} from "@web_timeline_gantt_ux/gantt_layout/controller_layout_patch.esm"; + +const RECORDS = [ + {id: 42, project_id: [10, "P10"]}, + {id: 43, project_id: false}, +]; + +describe("web_timeline_gantt_ux controller add remap", () => { + test("parent band id resolves to the group-by value id", () => { + expect(remapAddGroup("grp_10", RECORDS, "project_id")).toBe(10); + }); + + test("task row id resolves through the record's group-by value", () => { + expect(remapAddGroup("rec_42", RECORDS, "project_id")).toBe(10); + expect(remapAddGroup("rec_43", RECORDS, "project_id")).toBe(-1); + expect(remapAddGroup("rec_999", RECORDS, "project_id")).toBe(-1); + }); + + test("unassigned and unknown ids resolve to -1", () => { + expect(remapAddGroup("grp_unassigned", RECORDS, "project_id")).toBe(-1); + expect(remapAddGroup("weird", RECORDS, "project_id")).toBe(-1); + }); +}); diff --git a/web_timeline_gantt_ux/static/tests/controller_move.test.js b/web_timeline_gantt_ux/static/tests/controller_move.test.js new file mode 100644 index 00000000000..f5c62020895 --- /dev/null +++ b/web_timeline_gantt_ux/static/tests/controller_move.test.js @@ -0,0 +1,70 @@ +/** @odoo-module **/ + +import {describe, expect, test} from "@odoo/hoot"; +import {TimelineController} from "@web_timeline/views/timeline/timeline_controller.esm"; + +// CRITICAL regression guard for the moveQueue coupling: the gantt patch +// deletes the group-by field from the entry the STOCK _onMove just pushed to +// this.moveQueue (a private structure). If an OCA web_timeline bump reshapes +// that queue, this test fails loudly instead of every bar drag failing with +// e.g. `project_id: "rec_42"` server errors in production. + +function makeController(ganttUx) { + return { + date_start: "planned_date_start", + date_stop: "planned_date_end", + date_delay: null, + model: { + serializeDate: (field, dt) => dt.toISO(), + fields: {project_id: {type: "many2one"}}, + last_group_bys: ["project_id"], + }, + moveQueue: [], + debouncedInternalMove: () => { + return; + }, + props: {modelParams: {gantt_ux: ganttUx}}, + }; +} + +describe("web_timeline_gantt_ux controller move", () => { + test("gantt mode strips the group-by field from the queued move", () => { + const controller = makeController(true); + TimelineController.prototype._onMove.call( + controller, + { + id: 42, + start: new Date(2026, 6, 1), + end: new Date(2026, 6, 3), + group: "rec_42", + }, + () => { + return; + } + ); + expect(controller.moveQueue.length).toBe(1); + const queued = controller.moveQueue[0]; + expect(queued.id).toBe(42); + expect("project_id" in queued.data).toBe(false); + expect("planned_date_start" in queued.data).toBe(true); + expect("planned_date_end" in queued.data).toBe(true); + }); + + test("stock mode still writes the group-by field", () => { + const controller = makeController(false); + TimelineController.prototype._onMove.call( + controller, + { + id: 42, + start: new Date(2026, 6, 1), + end: new Date(2026, 6, 3), + group: 10, + }, + () => { + return; + } + ); + expect(controller.moveQueue.length).toBe(1); + expect(controller.moveQueue[0].data.project_id).toBe(10); + }); +}); diff --git a/web_timeline_gantt_ux/static/tests/dragger_validate.test.js b/web_timeline_gantt_ux/static/tests/dragger_validate.test.js new file mode 100644 index 00000000000..20ae447b8ce --- /dev/null +++ b/web_timeline_gantt_ux/static/tests/dragger_validate.test.js @@ -0,0 +1,69 @@ +/** @odoo-module **/ + +import {describe, expect, test} from "@odoo/hoot"; +import {validateLink} from "@web_timeline_gantt_ux/dependency_edit/dependency_link_dragger.esm"; + +const RECORDS = { + 1: {id: 1, display_name: "A", depend_on_ids: [], allow: true}, + 2: {id: 2, display_name: "B", depend_on_ids: [1], allow: true}, + 3: {id: 3, display_name: "C", depend_on_ids: [], allow: false}, +}; + +function validate(predecessorId, successorId, extra = {}) { + return validateLink({ + predecessorId, + successorId, + getRecord: (id) => RECORDS[id], + depField: "depend_on_ids", + canLink: (rec) => rec.allow, + isPending: () => false, + ...extra, + }); +} + +describe("web_timeline_gantt_ux link validation", () => { + test("valid link passes", () => { + expect(validate(2, 1)).toBe(null); + }); + + test("self-link rejected", () => { + expect(validate(1, 1)).toInclude("cannot depend on itself"); + }); + + test("unknown record rejected", () => { + expect(validate(1, 99)).toInclude("Unknown task"); + }); + + test("feature-gated record rejected", () => { + expect(validate(1, 3)).toInclude("disabled"); + expect(validate(3, 1)).toInclude("disabled"); + }); + + test("duplicate link rejected", () => { + // B is already blocked by A. + expect(validate(1, 2)).toInclude("already blocked by"); + }); + + test("reverse duplicate rejected", () => { + // A -> B exists; linking B as blocked-by would conflict. + expect(validate(2, 1, {getRecord: (id) => RECORDS[id]})).toBe(null); + const records = { + 1: {id: 1, display_name: "A", depend_on_ids: [2], allow: true}, + 2: {id: 2, display_name: "B", depend_on_ids: [], allow: true}, + }; + expect( + validateLink({ + predecessorId: 1, + successorId: 2, + getRecord: (id) => records[id], + depField: "depend_on_ids", + }) + ).toInclude("reverse link"); + }); + + test("in-flight pair locked", () => { + expect(validate(2, 1, {isPending: () => true})).toInclude( + "already being saved" + ); + }); +}); diff --git a/web_timeline_gantt_ux/static/tests/gantt_groups.test.js b/web_timeline_gantt_ux/static/tests/gantt_groups.test.js new file mode 100644 index 00000000000..f0b8c118a53 --- /dev/null +++ b/web_timeline_gantt_ux/static/tests/gantt_groups.test.js @@ -0,0 +1,93 @@ +/** @odoo-module **/ + +import {describe, expect, test} from "@odoo/hoot"; +import {buildGanttGroups} from "@web_timeline_gantt_ux/gantt_layout/renderer_layout_patch.esm"; + +const RECORDS = [ + {id: 1, display_name: "Alpha", project_id: [10, "P10"], date_start: "2026-07-01"}, + {id: 2, display_name: "Beta", project_id: [10, "P10"], date_start: false}, + {id: 3, display_name: "Gamma", project_id: [20, "P20"], date_start: "2026-07-02"}, + {id: 4, display_name: "Delta", project_id: false, date_start: "2026-07-03"}, +]; + +describe("web_timeline_gantt_ux group building", () => { + test("one parent per group-by value, one namespaced row per record", () => { + const groups = buildGanttGroups(RECORDS, "project_id", new Set(), "date_start"); + const parents = groups.filter((g) => g.nestedGroups); + const rows = groups.filter((g) => !g.nestedGroups); + expect(parents.map((g) => g.id)).toEqual([ + "grp_10", + "grp_20", + "grp_unassigned", + ]); + expect(rows.map((g) => g.id)).toEqual(["rec_1", "rec_2", "rec_3", "rec_4"]); + // Every row is level 1 (vis red-borders unknown levels) and belongs + // to its parent's nestedGroups. + expect(rows.every((g) => g.treeLevel === 1)).toBe(true); + expect(parents[0].nestedGroups).toEqual(["rec_1", "rec_2"]); + expect(parents[2].nestedGroups).toEqual(["rec_4"]); + }); + + test("parent bands carry name and task count; unassigned sorts first", () => { + const groups = buildGanttGroups(RECORDS, "project_id", new Set(), "date_start"); + const parents = groups.filter((g) => g.nestedGroups); + expect(parents[0].content).toInclude("P10"); + expect(parents[0].content).toInclude("(2)"); + const unassigned = parents.find((g) => g.id === "grp_unassigned"); + expect(unassigned.order).toBe(-1); + }); + + test("collapse state is applied via showNested", () => { + const groups = buildGanttGroups( + RECORDS, + "project_id", + new Set(["grp_10"]), + "date_start" + ); + const byId = Object.fromEntries(groups.map((g) => [g.id, g])); + expect(byId.grp_10.showNested).toBe(false); + expect(byId.grp_20.showNested).toBe(true); + }); + + test("row order is stable regardless of fetch order", () => { + // The search_read is ordered by the group-by field only; in-parent + // tie order from the database is arbitrary and MUST not leak into + // row order (rows would shuffle on every reload). + // In-parent tie order flipped (rec_2 before rec_1). + const shuffled = [RECORDS[1], RECORDS[0], RECORDS[2], RECORDS[3]]; + const a = buildGanttGroups(RECORDS, "project_id", new Set(), "date_start"); + const b = buildGanttGroups(shuffled, "project_id", new Set(), "date_start"); + for (const parentA of a.filter((g) => g.nestedGroups)) { + const parentB = b.find((g) => g.id === parentA.id); + expect(parentB.nestedGroups).toEqual(parentA.nestedGroups); + } + // Vis places rows by their order property: relative order within a + // parent must match the stable ordering, not the fetch order. + const rowOrder = (groups, id) => groups.find((g) => g.id === id).order; + expect(rowOrder(b, "rec_1") < rowOrder(b, "rec_2")).toBe(true); + }); + + test("task rows are blank alignment tracks; the name rides on the bar", () => { + const groups = buildGanttGroups(RECORDS, "project_id", new Set(), "date_start"); + const rows = groups.filter((g) => !g.nestedGroups); + expect(rows.every((g) => g.content === "")).toBe(true); + expect(rows[0].title).toBe("Alpha"); + }); + + test("unscheduled records get the marker class", () => { + const groups = buildGanttGroups(RECORDS, "project_id", new Set(), "date_start"); + const byId = Object.fromEntries(groups.map((g) => [g.id, g])); + expect(byId.rec_2.className).toInclude("o_tlg_unscheduled"); + expect(byId.rec_1.className).not.toInclude("o_tlg_unscheduled"); + }); + + test("m2m group-by uses the first value with a placeholder label", () => { + const records = [ + {id: 5, display_name: "Eps", tag_ids: [7, 8], date_start: "2026-07-01"}, + ]; + const groups = buildGanttGroups(records, "tag_ids", new Set(), "date_start"); + const parent = groups.find((g) => g.nestedGroups); + expect(parent.id).toBe("grp_7"); + expect(parent.content).toInclude("#7"); + }); +}); diff --git a/web_timeline_gantt_ux/static/tests/model_remap.test.js b/web_timeline_gantt_ux/static/tests/model_remap.test.js new file mode 100644 index 00000000000..fe786ac1793 --- /dev/null +++ b/web_timeline_gantt_ux/static/tests/model_remap.test.js @@ -0,0 +1,54 @@ +/** @odoo-module **/ + +import {describe, expect, test} from "@odoo/hoot"; +import {TimelineModel} from "@web_timeline/views/timeline/timeline_model.esm"; +import {ganttItemStyle} from "@web_timeline_gantt_ux/gantt_layout/model_patch.esm"; + +const {DateTime} = luxon; + +function makeModel(ganttUx) { + // Bare receiver for the prototype method: stubs everything the stock + // _event_data_transform touches. + return { + params: {gantt_ux: ganttUx}, + last_group_bys: ["project_id"], + colors: [], + recordTemplate: null, + _get_event_dates: () => [DateTime.now(), null], + }; +} + +describe("web_timeline_gantt_ux model remap", () => { + test("gantt mode namespaces the item group, ids stay raw", () => { + const model = makeModel(true); + const item = TimelineModel.prototype._event_data_transform.call(model, { + id: 42, + display_name: "Task", + project_id: [10, "P10"], + }); + expect(item.group).toBe("rec_42"); + expect(item.id).toBe(42); + }); + + test("stock mode keeps the group-by value", () => { + const model = makeModel(false); + const item = TimelineModel.prototype._event_data_transform.call(model, { + id: 42, + display_name: "Task", + project_id: [10, "P10"], + }); + expect(item.group).toBe(10); + }); + + test("ganttItemStyle converts matched colors to a custom property", () => { + expect(ganttItemStyle("background-color: #a8dbc0;")).toEqual({ + style: "--o-tlg-item-color: #a8dbc0;", + colored: true, + }); + expect(ganttItemStyle("background-color: false;")).toEqual({ + style: "", + colored: false, + }); + expect(ganttItemStyle("")).toEqual({style: "", colored: false}); + }); +}); diff --git a/web_timeline_gantt_ux/static/tests/move_cascade.test.js b/web_timeline_gantt_ux/static/tests/move_cascade.test.js new file mode 100644 index 00000000000..4fd638889cc --- /dev/null +++ b/web_timeline_gantt_ux/static/tests/move_cascade.test.js @@ -0,0 +1,250 @@ +/** @odoo-module **/ + +import {describe, expect, test} from "@odoo/hoot"; +import { + classifyMove, + collectDependentShifts, +} from "@web_timeline_gantt_ux/dependency_edit/move_cascade.esm"; +import {TimelineController} from "@web_timeline/views/timeline/timeline_controller.esm"; + +const {DateTime} = luxon; +const DAY_MS = 24 * 60 * 60 * 1000; + +describe("web_timeline_gantt_ux move classification", () => { + const d = (iso) => new Date(iso); + + test("center drag (duration preserved) is a time move with its delta", () => { + const {timeMove, deltaMs} = classifyMove({ + oldStart: d("2026-07-01T08:00:00Z"), + oldEnd: d("2026-07-03T08:00:00Z"), + newStart: d("2026-07-03T08:00:00Z"), + newEnd: d("2026-07-05T08:00:00Z"), + }); + expect(timeMove).toBe(true); + expect(deltaMs).toBe(2 * DAY_MS); + }); + + test("edge resize (duration changed) is not a time move", () => { + expect( + classifyMove({ + oldStart: d("2026-07-01T08:00:00Z"), + oldEnd: d("2026-07-03T08:00:00Z"), + newStart: d("2026-07-01T08:00:00Z"), + newEnd: d("2026-07-04T08:00:00Z"), + }).timeMove + ).toBe(false); + }); + + test("zero delta and missing bounds are not time moves", () => { + const same = d("2026-07-01T08:00:00Z"); + expect( + classifyMove({oldStart: same, oldEnd: null, newStart: same, newEnd: null}) + .timeMove + ).toBe(false); + expect( + classifyMove({oldStart: null, oldEnd: null, newStart: same, newEnd: null}) + .timeMove + ).toBe(false); + }); +}); + +describe("web_timeline_gantt_ux dependent shifts", () => { + // B blocked by A; C blocked by B (chain). D independent. E blocked by A + // but unscheduled; F blocked by E (must still shift, through E). + const RECORDS = [ + {id: 1, display_name: "A", depend_on_ids: [], date_start: "2026-07-01"}, + {id: 2, display_name: "B", depend_on_ids: [1], date_start: "2026-07-05"}, + {id: 3, display_name: "C", depend_on_ids: [2], date_start: "2026-07-09"}, + {id: 4, display_name: "D", depend_on_ids: [], date_start: "2026-07-01"}, + {id: 5, display_name: "E", depend_on_ids: [1], date_start: false}, + {id: 6, display_name: "F", depend_on_ids: [5], date_start: "2026-07-11"}, + ]; + + test("transitive downstream closure with per-root delta", () => { + const shifts = collectDependentShifts(RECORDS, "depend_on_ids", "date_start", [ + {id: 1, deltaMs: DAY_MS}, + ]); + expect([...shifts.keys()].sort()).toEqual([2, 3, 6]); + expect(shifts.get(2)).toBe(DAY_MS); + expect(shifts.get(3)).toBe(DAY_MS); + }); + + test("unscheduled dependents are traversed but not shifted", () => { + const shifts = collectDependentShifts(RECORDS, "depend_on_ids", "date_start", [ + {id: 1, deltaMs: DAY_MS}, + ]); + expect(shifts.has(5)).toBe(false); + expect(shifts.get(6)).toBe(DAY_MS); + }); + + test("moved records never appear in the result; cycles terminate", () => { + const cyclic = [ + {id: 1, depend_on_ids: [2], date_start: "2026-07-01"}, + {id: 2, depend_on_ids: [1], date_start: "2026-07-02"}, + ]; + const shifts = collectDependentShifts(cyclic, "depend_on_ids", "date_start", [ + {id: 1, deltaMs: DAY_MS}, + ]); + expect(shifts.has(1)).toBe(false); + expect([...shifts.keys()]).toEqual([2]); + }); + + test("no dependents means no prompt candidates", () => { + const shifts = collectDependentShifts(RECORDS, "depend_on_ids", "date_start", [ + {id: 4, deltaMs: DAY_MS}, + ]); + expect(shifts.size).toBe(0); + }); +}); + +describe("web_timeline_gantt_ux cascade drain", () => { + // Fake controller exercising the patched internalMove end to end with a + // scripted dialog choice. Mirrors the moveQueue coupling guard in + // controller_move.test.js. + function makeController({choice, records}) { + const writes = []; + const callbacks = []; + let loads = 0; + let renders = 0; + let prompted = 0; + const fields = { + planned_date_start: {type: "datetime"}, + planned_date_end: {type: "datetime"}, + }; + const controller = { + date_start: "planned_date_start", + date_stop: "planned_date_end", + props: {modelParams: {gantt_ux: true}}, + model: { + params: {dependency_arrow: "depend_on_ids"}, + data: records, + fields, + parseDate: (field, value) => DateTime.fromISO(value, {zone: "utc"}), + serializeDate: (field, dt) => dt.toISO(), + write_completed: (id, vals) => { + writes.push({id, vals}); + return Promise.resolve(); + }, + load: () => { + loads++; + return Promise.resolve(); + }, + }, + getSearchProps: () => ({}), + render: () => { + renders++; + }, + dialogService: { + add: (component, props) => { + prompted++; + props.onChoice(choice); + }, + }, + tlgNotification: { + add: () => { + return; + }, + }, + moveQueue: [], + }; + controller.spy = { + writes, + callbacks, + counters: () => ({loads, renders, prompted}), + }; + // The drain calls sibling prototype methods (_tlgGanttInternalMove, + // _tlgCascadeBody, _tlgShiftedDates); a plain object fake cannot + // reach them without the prototype chain. + Object.setPrototypeOf(controller, TimelineController.prototype); + return controller; + } + + const RECORDS = [ + { + id: 1, + display_name: "A", + depend_on_ids: [], + planned_date_start: "2026-07-01T08:00:00Z", + planned_date_end: "2026-07-03T08:00:00Z", + }, + { + id: 2, + display_name: "B", + depend_on_ids: [1], + planned_date_start: "2026-07-05T08:00:00Z", + planned_date_end: "2026-07-06T08:00:00Z", + }, + ]; + + function queueMoveOfA(controller) { + controller.moveQueue.push({ + id: 1, + data: { + planned_date_start: "2026-07-02 08:00:00", + planned_date_end: "2026-07-04 08:00:00", + }, + item: { + id: 1, + start: new Date("2026-07-02T08:00:00Z"), + end: new Date("2026-07-04T08:00:00Z"), + evt: RECORDS[0], + }, + callback: (arg) => controller.spy.callbacks.push(arg), + }); + } + + test("cascade choice writes the moved task and shifts the dependent", async () => { + const controller = makeController({choice: "cascade", records: RECORDS}); + queueMoveOfA(controller); + await TimelineController.prototype.internalMove.call(controller); + const {writes, callbacks, counters} = controller.spy; + expect(counters().prompted).toBe(1); + expect(writes.length).toBe(2); + expect(writes[0].id).toBe(1); + expect(writes[1].id).toBe(2); + // B shifted by the same +1 day, time-of-day preserved. + expect(writes[1].vals.planned_date_start).toInclude("2026-07-06T08:00"); + expect(writes[1].vals.planned_date_end).toInclude("2026-07-07T08:00"); + expect(callbacks.length).toBe(1); + expect(callbacks[0]).not.toBe(null); + expect(counters().loads).toBe(1); + expect(counters().renders).toBe(1); + }); + + test("single choice writes only the moved task", async () => { + const controller = makeController({choice: "single", records: RECORDS}); + queueMoveOfA(controller); + await TimelineController.prototype.internalMove.call(controller); + expect(controller.spy.writes.length).toBe(1); + expect(controller.spy.writes[0].id).toBe(1); + expect(controller.spy.counters().loads).toBe(1); + }); + + test("abort snaps the bar back and writes nothing", async () => { + const controller = makeController({choice: "abort", records: RECORDS}); + queueMoveOfA(controller); + await TimelineController.prototype.internalMove.call(controller); + expect(controller.spy.writes.length).toBe(0); + expect(controller.spy.callbacks).toEqual([null]); + expect(controller.spy.counters().loads).toBe(0); + }); + + test("no dependents: no prompt, stock-like write", async () => { + const noDeps = RECORDS.map((rec) => ({...rec, depend_on_ids: []})); + const controller = makeController({choice: "cascade", records: noDeps}); + controller.moveQueue.push({ + id: 1, + data: {planned_date_start: "2026-07-02 08:00:00"}, + item: { + id: 1, + start: new Date("2026-07-02T08:00:00Z"), + end: new Date("2026-07-04T08:00:00Z"), + evt: noDeps[0], + }, + callback: (arg) => controller.spy.callbacks.push(arg), + }); + await TimelineController.prototype.internalMove.call(controller); + expect(controller.spy.counters().prompted).toBe(0); + expect(controller.spy.writes.length).toBe(1); + }); +}); diff --git a/web_timeline_gantt_ux/static/tests/write_dependency.test.js b/web_timeline_gantt_ux/static/tests/write_dependency.test.js new file mode 100644 index 00000000000..48f89acc52f --- /dev/null +++ b/web_timeline_gantt_ux/static/tests/write_dependency.test.js @@ -0,0 +1,104 @@ +/** @odoo-module **/ + +import {describe, expect, test} from "@odoo/hoot"; +import {TimelineController} from "@web_timeline/views/timeline/timeline_controller.esm"; + +function makeController({writeError} = {}) { + const calls = {writes: [], loads: 0, renders: 0, notifications: []}; + const controller = { + model: { + params: {dependency_arrow: "depend_on_ids"}, + write_completed: (id, vals) => { + calls.writes.push([id, vals]); + return writeError ? Promise.reject(writeError) : Promise.resolve(); + }, + load: () => { + calls.loads++; + return Promise.resolve(); + }, + data: [ + {id: 1, display_name: "A"}, + {id: 2, display_name: "B"}, + ], + }, + getSearchProps: () => ({}), + render: () => { + calls.renders++; + }, + tlgNotification: { + add: (message, options) => { + calls.notifications.push([message, options]); + return () => { + return; + }; + }, + }, + }; + return {controller, calls}; +} + +describe("web_timeline_gantt_ux dependency writes", () => { + test("successful write reloads once and reports success", async () => { + const {controller, calls} = makeController(); + const created = await TimelineController.prototype._tlgWriteDependency.call( + controller, + 2, + [[4, 1]] + ); + expect(created).toBe(true); + expect(calls.writes).toEqual([[2, {depend_on_ids: [[4, 1]]}]]); + expect(calls.loads).toBe(1); + expect(calls.renders).toBe(1); + }); + + test("server ValidationError becomes a danger toast, no reload", async () => { + const {controller, calls} = makeController({ + writeError: { + exceptionName: "odoo.exceptions.ValidationError", + data: {message: "cycle detected"}, + }, + }); + const created = await TimelineController.prototype._tlgWriteDependency.call( + controller, + 2, + [[4, 1]] + ); + expect(created).toBe(false); + expect(calls.loads).toBe(0); + expect(calls.notifications.length).toBe(1); + expect(calls.notifications[0][0]).toInclude("cycle detected"); + expect(calls.notifications[0][1].type).toBe("danger"); + }); + + test("unexpected errors are rethrown to the crash handler", async () => { + const boom = new Error("boom"); + const {controller, calls} = makeController({writeError: boom}); + let caught = null; + try { + await TimelineController.prototype._tlgWriteDependency.call(controller, 2, [ + [4, 1], + ]); + } catch (error) { + caught = error; + } + expect(caught).toBe(boom); + expect(calls.loads).toBe(0); + expect(calls.notifications.length).toBe(0); + }); + + test("create success shows the canonical Undo toast", async () => { + const {controller, calls} = makeController(); + await TimelineController.prototype._onCreateDependency.call(controller, { + predecessorId: 1, + successorId: 2, + }); + expect(calls.notifications.length).toBe(1); + const [message, options] = calls.notifications[0]; + expect(message).toInclude('"B" is now blocked by "A"'); + expect(options.type).toBe("success"); + expect(options.buttons[0].name).toBe("Undo"); + // Undo issues the unlink write on the successor. + await options.buttons[0].onClick(); + expect(calls.writes[1]).toEqual([2, {depend_on_ids: [[3, 1]]}]); + }); +}); diff --git a/web_timeline_gantt_ux/tests/__init__.py b/web_timeline_gantt_ux/tests/__init__.py new file mode 100644 index 00000000000..d9b96c4fa5a --- /dev/null +++ b/web_timeline_gantt_ux/tests/__init__.py @@ -0,0 +1 @@ +from . import test_module diff --git a/web_timeline_gantt_ux/tests/test_module.py b/web_timeline_gantt_ux/tests/test_module.py new file mode 100644 index 00000000000..81338cf119e --- /dev/null +++ b/web_timeline_gantt_ux/tests/test_module.py @@ -0,0 +1,22 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo.tests.common import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestModule(TransactionCase): + def test_module_installed(self): + module = self.env["ir.module.module"].search( + [("name", "=", "web_timeline_gantt_ux")] + ) + self.assertTrue(module) + self.assertEqual(module.state, "installed") + + def test_backend_assets_registered(self): + # The asset definitions must survive a manifest typo: resolving the + # backend bundle must include this addon's files. + bundle = self.env["ir.asset"]._get_asset_paths("web.assets_backend", {}) + paths = [entry[0] for entry in bundle] + self.assertTrue( + any("web_timeline_gantt_ux" in path for path in paths), + "web_timeline_gantt_ux assets missing from web.assets_backend", + )