From 956cdd9f423ee4e848d79bfe055bcb12aa9768fd Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 16 May 2026 17:48:33 +0200 Subject: [PATCH 001/105] Document Backlogs drag and drop language --- frontend/CONTEXT.md | 64 +++++++++++++++++++++++++++++++++++++ modules/backlogs/CONTEXT.md | 52 ++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 frontend/CONTEXT.md create mode 100644 modules/backlogs/CONTEXT.md diff --git a/frontend/CONTEXT.md b/frontend/CONTEXT.md new file mode 100644 index 000000000000..e93fe8b569bb --- /dev/null +++ b/frontend/CONTEXT.md @@ -0,0 +1,64 @@ +# Frontend + +The OpenProject frontend context describes user interface interaction language shared across frontend features. + +## Language + +**Drag preview**: +The temporary visual representation that follows the pointer during a drag. +_Avoid_: Mirror + +**Drag source**: +The original draggable element while it is being dragged. +_Avoid_: Placeholder + +**Drop indicator**: +A visual marker that shows where a dragged object will be placed. +_Avoid_: Placeholder + +**Drop placeholder**: +A reserved space that approximates the size of the dragged object at a candidate drop location. +_Avoid_: Drop indicator + +**Drop target**: +An area that can accept or reject a dragged object. +_Avoid_: Container + +**Empty drop zone**: +An empty area that can accept a dragged object when there is no existing object to anchor a drop indicator. +_Avoid_: Placeholder + +**Sortable item**: +An object that can be repositioned by drag and drop within or between sortable lists. +_Avoid_: Draggable item + +**Sortable item type**: +A category used to decide whether a sortable item may be dropped into a sortable list. +_Avoid_: Draggable type + +**Sortable list**: +An ordered list whose sortable items can be repositioned by drag and drop. +_Avoid_: Container + +## Relationships + +- A **Drag source** remains at the original location while the **Drag preview** follows the pointer. +- A **Drop indicator** marks a candidate location without reserving full object-sized space. +- A **Drop placeholder** reserves object-sized space at a candidate location. +- An **Empty drop zone** may combine the affordances of a **Drop target**, **Drop indicator**, and **Drop placeholder**. +- A **Sortable item** has a **Sortable item type**. +- A **Sortable list** may accept only specific **Sortable item types**. + +## Example Dialogue + +> **Dev:** "What should we call the floating visual during a drag?" +> **Domain expert:** "The **Drag preview**." +> **Dev:** "Is an 8px highlighted gap a placeholder?" +> **Domain expert:** "No — it is a **Drop indicator** because it marks a candidate location without reserving full object-sized space." + +## Flagged Ambiguities + +- "mirror" is Dragula-specific — resolved: use **Drag preview**. +- "placeholder" was used for highlighted gaps — resolved: use **Drop indicator** unless full object-sized space is reserved. +- "container" was used for drop areas — resolved: use **Drop target** for drag-and-drop interaction language. +- "draggable type" was inherited from the generic drag-and-drop controller — resolved: use **Sortable item type**. diff --git a/modules/backlogs/CONTEXT.md b/modules/backlogs/CONTEXT.md new file mode 100644 index 000000000000..8ef06996e43e --- /dev/null +++ b/modules/backlogs/CONTEXT.md @@ -0,0 +1,52 @@ +# Backlogs + +Backlogs is the OpenProject context for planning and ordering project work across an inbox backlog, backlog buckets, and sprints. + +## Language + +**Work package**: +A unit of project work that can be planned, tracked, and moved through Backlogs. +_Avoid_: Item, story + +**Work package card**: +The visual representation of a work package in a Backlogs list. +_Avoid_: Item, row + +**Backlogs list**: +An ordered Backlogs view location that can contain work packages. +_Avoid_: Container, target + +**Position**: +A user-controlled ordinal value that determines where a work package appears within an ordered list. +_Avoid_: Priority order, sort order + +**Highlighted gap drop indicator**: +An accent-tinted gap between work package cards that marks the candidate position for a dragged work package. +_Avoid_: Placeholder, drop placeholder + +## Relationships + +- A **Work package** may appear in a Backlog, a Backlog bucket, or a Sprint. +- A **Work package card** represents one **Work package** in a Backlogs list. +- An **Inbox backlog**, a **Backlog bucket**, and a **Sprint** are Backlogs lists. +- A **Work package** has a **Position** within an ordered list. +- A **Highlighted gap drop indicator** appears at the candidate **Position** for a dragged work package. + +## Example Dialogue + +> **Dev:** "When a user drags a card from a Backlog bucket into a Sprint, what moved?" +> **Domain expert:** "A **Work package** moved; the card is only how that work package is shown in the Backlogs view." +> **Dev:** "What did it move between?" +> **Domain expert:** "Between **Backlogs lists**." +> **Dev:** "What changes when a Work package is dragged within the same Backlogs list?" +> **Domain expert:** "Its **Position** changes." +> **Dev:** "What is the highlighted gap between two cards during drag?" +> **Domain expert:** "A **Highlighted gap drop indicator**." + +## Flagged Ambiguities + +- "item" was used to mean **Work package** — resolved: use **Work package** for the domain object. +- "card" was used ambiguously — resolved: use **Work package card** for the visual representation and **Work package** for the domain object. +- "container" was used to mean **Backlogs list** — resolved: use **Backlogs list** for the domain concept and reserve "list" for generic implementation discussions. +- "priority order" conflicts with the separate Work package priority attribute — resolved: use **Position**. +- "placeholder" was used for the highlighted gap — resolved: use **Highlighted gap drop indicator** because it marks a candidate Position without reserving full card-sized space. From 66bbb7e7a69f012751b1221c4f0fb8bf0b8df927 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 29 Apr 2026 18:56:24 +0200 Subject: [PATCH 002/105] Add Pragmatic Drag and Drop dependencies --- frontend/package-lock.json | 46 ++++++++++++++++++++++++++++++++++++++ frontend/package.json | 3 +++ 2 files changed, 49 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a8aa2dbf2248..c61d7815576a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,9 @@ "@appsignal/javascript": "^1.6.1", "@appsignal/plugin-breadcrumbs-console": "^1.1.37", "@appsignal/plugin-breadcrumbs-network": "^1.1.24", + "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0", "@blocknote/core": "^0.51.4", "@blocknote/mantine": "^0.51.4", "@blocknote/react": "^0.51.3", @@ -1685,6 +1688,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@atlaskit/pragmatic-drag-and-drop": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.8.1.tgz", + "integrity": "sha512-uXWNPpL8n4OmTVbduH7nq8pk8htqGo/prR5cYEE8sVCPJGAUMWn6lzvWTfI+4VCeQvHiDRODVz4YzH06OVAxhw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.0.0", + "bind-event-listener": "^3.0.0", + "raf-schd": "^4.0.3" + } + }, + "node_modules/@atlaskit/pragmatic-drag-and-drop-auto-scroll": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-auto-scroll/-/pragmatic-drag-and-drop-auto-scroll-2.1.5.tgz", + "integrity": "sha512-InLvVhZAHPBfv3CxuG4AfOQuhNJjaFy69YBfodPMWtRFQNQAKa9Yb3vL9Ho6qsD9qKUBuJa4A5k7QddaXQ4Eyw==", + "license": "Apache-2.0", + "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.7.0", + "@babel/runtime": "^7.0.0" + } + }, + "node_modules/@atlaskit/pragmatic-drag-and-drop-hitbox": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.2.0.tgz", + "integrity": "sha512-eWJvvuHZOC4Yk+Li7QpS+JM2F/I50/3PhMvEcyvcHbXI0FP0kCDD1MiF8Hv7uSOxpk5JNqKoOmK8e1ncOzTgqA==", + "license": "Apache-2.0", + "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.8.0", + "@babel/runtime": "^7.0.0" + } + }, "node_modules/@authress/login": { "version": "2.6.417", "resolved": "https://registry.npmjs.org/@authress/login/-/login-2.6.417.tgz", @@ -10917,6 +10951,12 @@ "node": ">=8" } }, + "node_modules/bind-event-listener": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz", + "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -18793,6 +18833,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", + "license": "MIT" + }, "node_modules/randexp": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index c4b4e2e78c8b..692117ece089 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -71,6 +71,9 @@ "@appsignal/javascript": "^1.6.1", "@appsignal/plugin-breadcrumbs-console": "^1.1.37", "@appsignal/plugin-breadcrumbs-network": "^1.1.24", + "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0", "@blocknote/core": "^0.51.4", "@blocknote/mantine": "^0.51.4", "@blocknote/react": "^0.51.3", From f18c69e7738bafa2eebeb3c19719b0a368cebc56 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 8 Jun 2026 19:46:31 +0100 Subject: [PATCH 003/105] [AGILE-251] Port backlogs to Pragmatic DnD Replaces the previous Dragula-oriented card/list wiring with Pragmatic Drag and Drop Stimulus controllers for backlog cards and list targets. Keeps draggable card state client-side, submits moves through the existing backlogs move endpoints, and tolerates empty drop-target reports by resolving the element under the pointer. Renders the draggable attribute and Stimulus item id from the server so Turbo morphs preserve the DnD contract. Unifies the move-target encoding on a list_type/list_id pair across the drag JS, move dialogs, and card menu, dropping the legacy target_id string so the service resolves the destination list directly. Renders both move dialogs through the Primer forms DSL instead of safe_join. https://community.openproject.org/wp/AGILE-251 --- .../work_package_item.rb | 2 +- .../work_package_card_component.html.erb | 6 +- .../common/work_package_card_component.rb | 18 +- .../common/work_package_card_component.sass | 4 + .../src/global_styles/primer/_overrides.sass | 66 +- .../backlogs/list-refresh.controller.spec.ts | 182 ++++++ .../list-refresh.controller.ts} | 33 +- .../dynamic/sortable-lists.controller.spec.ts | 613 ++++++++++++++++++ .../dynamic/sortable-lists.controller.ts | 321 +++++++++ .../sortable-lists/drag-and-drop.spec.ts | 439 +++++++++++++ .../dynamic/sortable-lists/drag-and-drop.ts | 306 +++++++++ .../sortable-lists/item.controller.spec.ts | 526 +++++++++++++++ .../dynamic/sortable-lists/item.controller.ts | 280 ++++++++ .../stimulus/helpers/request-helpers.spec.ts | 77 +++ .../src/stimulus/helpers/request-helpers.ts | 5 +- .../backlogs/bucket_component.html.erb | 4 +- .../backlogs/inbox_component.html.erb | 8 +- .../move_to_bucket_dialog_component.html.erb | 19 +- .../move_to_sprint_dialog_component.html.erb | 19 +- .../backlogs/sprint_component.html.erb | 4 +- .../backlogs/work_package_card_component.rb | 6 +- .../work_package_card_list_component.rb | 10 +- .../work_package_card_list_item_component.rb | 44 +- .../work_package_card_menu_component.html.erb | 2 +- .../work_package_card_menu_component.rb | 20 +- .../backlogs/work_packages_controller.rb | 11 +- .../backlogs/work_packages/update_service.rb | 20 +- .../backlogs/backlog/_backlog_list.html.erb | 17 +- .../app/views/backlogs/backlog/show.html.erb | 3 - modules/backlogs/config/locales/en.yml | 6 +- .../backlogs/bucket_component_spec.rb | 44 +- .../backlogs/inbox_component_spec.rb | 10 +- .../move_to_bucket_dialog_component_spec.rb | 9 +- .../move_to_sprint_dialog_component_spec.rb | 9 +- .../backlogs/sprint_component_spec.rb | 35 +- .../backlogs/story_points_component_spec.rb | 8 + .../work_package_card_component_spec.rb | 6 + .../work_package_card_list_component_spec.rb | 30 +- ...k_package_card_list_item_component_spec.rb | 83 ++- .../work_package_card_menu_component_spec.rb | 8 + .../backlogs/work_packages_controller_spec.rb | 82 ++- .../spec/features/backlogs/edit_spec.rb | 21 + .../spec/features/inbox_column_spec.rb | 26 +- .../work_packages/drag_in_bucket_spec.rb | 45 +- .../work_packages/drag_in_inbox_spec.rb | 23 +- .../work_packages/drag_in_sprint_spec.rb | 43 +- .../work_packages/move_to_backlog_spec.rb | 14 +- .../spec/requests/backlogs/backlog_spec.rb | 29 + .../work_packages/update_service_spec.rb | 66 +- .../backlogs/spec/support/pages/backlog.rb | 504 +++++++++++++- .../common/border_box_list_component_spec.rb | 4 + .../work_package_card_component_spec.rb | 14 + 52 files changed, 3884 insertions(+), 300 deletions(-) create mode 100644 frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.spec.ts rename frontend/src/stimulus/controllers/dynamic/{backlogs.controller.ts => backlogs/list-refresh.controller.ts} (63%) create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts create mode 100644 frontend/src/stimulus/helpers/request-helpers.spec.ts diff --git a/app/components/open_project/common/border_box_list_component/work_package_item.rb b/app/components/open_project/common/border_box_list_component/work_package_item.rb index 32537fd9ca22..7ea8188307b6 100644 --- a/app/components/open_project/common/border_box_list_component/work_package_item.rb +++ b/app/components/open_project/common/border_box_list_component/work_package_item.rb @@ -110,7 +110,7 @@ def card # # @return [ApplicationComponent] def build_card - WorkPackageCardComponent.new(work_package:) + WorkPackageCardComponent.new(work_package:, classes: "Box-card") end def row_classes diff --git a/app/components/open_project/common/work_package_card_component.html.erb b/app/components/open_project/common/work_package_card_component.html.erb index e655a5d9cfcc..264769c9c102 100644 --- a/app/components/open_project/common/work_package_card_component.html.erb +++ b/app/components/open_project/common/work_package_card_component.html.erb @@ -27,11 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++# %> -<%= grid_layout( - "op-work-package-card", - tag: :article, - classes: layout_classes - ) do |grid| %> +<%= grid_layout("op-work-package-card", **@system_arguments) do |grid| %> <% if show_drag_handle %> <% grid.with_area(:drag_handle, classes: "hide-when-print") do %> <%= render(Primer::OpenProject::DragHandle.new) %> diff --git a/app/components/open_project/common/work_package_card_component.rb b/app/components/open_project/common/work_package_card_component.rb index ce4053ff52f5..af513852d067 100644 --- a/app/components/open_project/common/work_package_card_component.rb +++ b/app/components/open_project/common/work_package_card_component.rb @@ -60,9 +60,11 @@ class WorkPackageCardComponent < ApplicationComponent # Only rendered when the work package actually has a parent. # @param link_subject [Boolean] whether to link the subject to the WP or render as plain text instead. # @param status_scheme [Symbol] status label scheme for the info line. One of :default or :secondary. + # @param system_arguments [Hash] forwarded to the root card element. def initialize(work_package:, menu_src: nil, show_drag_handle: false, show_assignee: false, show_priority: false, show_parent: false, link_subject: true, - status_scheme: :default) + status_scheme: :default, + **system_arguments) super() @work_package = work_package @@ -73,16 +75,20 @@ def initialize(work_package:, menu_src: nil, show_drag_handle: false, @show_parent = show_parent @link_subject = link_subject @status_scheme = status_scheme + @system_arguments = system_arguments.except(:tag) + @system_arguments[:tag] = :article end private - def layout_classes - { + def before_render + @system_arguments[:classes] = class_names( + @system_arguments[:classes], "op-work-package-card_with-drag-handle": show_drag_handle?, - "op-work-package-card_with-footer": show_footer?, - "op-work-package-card_with-menu": menu? || menu_src.present? - } + "op-work-package-card_with-menu": menu? || menu_src.present?, + "op-work-package-card_with-metric": metric?, + "op-work-package-card_with-footer": show_footer? + ) end def show_parent? = show_parent && work_package.parent diff --git a/app/components/open_project/common/work_package_card_component.sass b/app/components/open_project/common/work_package_card_component.sass index 25c3ed1f136d..cfc9f4eb86fc 100644 --- a/app/components/open_project/common/work_package_card_component.sass +++ b/app/components/open_project/common/work_package_card_component.sass @@ -111,3 +111,7 @@ @media screen and (max-width: $breakpoint-sm) .op-work-package-card--priority-name display: none + +.op-work-package-card.Box-card + margin-top: 0 + margin-bottom: 0 diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 4a2d20ff1403..cf6a53d0d014 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -157,12 +157,6 @@ ul.SegmentedControl, .ActionListItem-label[class^="__hl_"], .ActionListItem-label[class*=" __hl_"] color: var(--control-fgColor-disabled) !important -// hide hover when dragging with legacy Dragula implementation -.Box-row--hover-gray, -.Box-row--hover-blue - body[data-dragging="active"] & - background-color: unset - .Box-row--focus-gray &:focus-visible background-color: var(--bgColor-muted) @@ -174,26 +168,62 @@ ul.SegmentedControl, .Box-row--clickable cursor: pointer -.Box-row--draggable:not(.Box-row--clickable) +.Box-row--draggable cursor: grab -// `:is(.Box-row--clickable)` bumps selector specificity so these rules win -// over Dragula's `.gu-*` transit styles; without it the mirror and source -// rows render transparent mid-drag. Revisit once #74172 / #73729 replace the -// legacy Dragula implementation. -.Box-row:is(.Box-row--clickable) - &[data-dragging="mirror"] + &:active + cursor: grabbing + +.Box-row:is(.Box-row--draggable) + --op-backlogs-drop-gap-border-width: 2px + --op-backlogs-drop-gap-height: 8px + + padding: 0 + + &[data-dragging="source"] + opacity: 0.4 + background-color: var(--bgColor-inset) + box-shadow: inset 0 0 0 var(--borderWidth-default) var(--borderColor-muted) + + &[data-drop-position] + position: relative + + &::before, + &::after + position: absolute + left: 0 + right: 0 + height: var(--op-backlogs-drop-gap-height) + background: linear-gradient( + to bottom, + var(--bgColor-accent-muted) 0, + var(--bgColor-accent-muted) calc(50% - var(--op-backlogs-drop-gap-border-width) / 2), + var(--fgColor-accent) calc(50% - var(--op-backlogs-drop-gap-border-width) / 2), + var(--fgColor-accent) calc(50% + var(--op-backlogs-drop-gap-border-width) / 2), + var(--bgColor-accent-muted) calc(50% + var(--op-backlogs-drop-gap-border-width) / 2), + var(--bgColor-accent-muted) 100% + ) + pointer-events: none + z-index: 1 + + &[data-drop-position="top"]::before + content: '' + top: calc(var(--op-backlogs-drop-gap-height) / -2) + + &[data-drop-position="bottom"]::after + content: '' + bottom: calc(var(--op-backlogs-drop-gap-height) / -2) + +.Box-card + padding: var(--stack-padding-condensed) var(--stack-padding-normal) + + &[data-preview] background-color: var(--bgColor-default) border: var(--borderWidth-default) solid var(--borderColor-default) border-radius: var(--borderRadius-medium) box-shadow: var(--shadow-floating-medium) opacity: 1 - &[data-dragging="source"] - opacity: 0.4 - background-color: var(--bgColor-subtle) - box-shadow: inset 0 0 0 var(--borderWidth-default) var(--borderColor-muted) - // Apply the mobile styles as soon as the banner itself is small // Styles are copied from the PVC repo .op-primer-flash diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.spec.ts new file mode 100644 index 000000000000..c0b2e07bee8a --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.spec.ts @@ -0,0 +1,182 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { waitFor } from '@testing-library/dom'; +import { Subject } from 'rxjs'; +import { vi, type Mock } from 'vitest'; + +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; +import type ListRefreshControllerType from './list-refresh.controller'; + +interface HalEvent { + eventType:string; +} + +describe('Backlogs list-refresh controller', () => { + let ctx:StimulusTestContext; + let ListRefreshController:typeof ListRefreshControllerType; + let events$:Subject; + let aggregated$:Mock; + let originalOpenProject:typeof window.OpenProject; + + beforeAll(async () => { + ({ default: ListRefreshController } = await import('./list-refresh.controller')); + }); + + beforeEach(async () => { + events$ = new Subject(); + aggregated$ = vi.fn().mockReturnValue(events$.asObservable()); + + originalOpenProject = window.OpenProject; + window.OpenProject = { + getPluginContext: () => Promise.resolve({ services: { halEvents: { aggregated$ } } }), + } as unknown as typeof window.OpenProject; + + ctx = await setupStimulusTest({ + controllers: { + 'backlogs--list-refresh': ListRefreshController, + }, + }); + }); + + afterEach(() => { + ctx.dispose(); + window.OpenProject = originalOpenProject; + vi.restoreAllMocks(); + }); + + // Mounts the frame and waits for the controller's async connect() to subscribe. + async function renderFrame() { + await ctx.mount(''); + const frame = ctx.container.querySelector('[data-controller="backlogs--list-refresh"]')!; + const reload = vi.fn(); + (frame as unknown as { reload:() => void }).reload = reload; + + // connect() resolves getPluginContext() asynchronously before subscribing. + await waitFor(() => { expect(aggregated$).toHaveBeenCalledWith('WorkPackage'); }); + + return { frame, reload }; + } + + it('subscribes to aggregated WorkPackage events', async () => { + await renderFrame(); + + expect(aggregated$).toHaveBeenCalledWith('WorkPackage'); + }); + + it('reloads the frame when an aggregated updated event arrives', async () => { + const { reload } = await renderFrame(); + + events$.next([{ eventType: 'updated' }]); + + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('ignores aggregated events without an updated entry', async () => { + const { reload } = await renderFrame(); + + events$.next([{ eventType: 'created' }, { eventType: 'removed' }]); + + expect(reload).not.toHaveBeenCalled(); + }); + + it('stops reloading after the controller disconnects', async () => { + const { frame, reload } = await renderFrame(); + + frame.remove(); + await ctx.nextFrame(); + + events$.next([{ eventType: 'updated' }]); + + expect(reload).not.toHaveBeenCalled(); + }); + + it('does not subscribe when disconnected before plugin context resolves', async () => { + let resolveContext!:(context:unknown) => void; + const contextPromise = new Promise((resolve) => { resolveContext = resolve; }); + + window.OpenProject = { + getPluginContext: () => contextPromise, + } as unknown as typeof window.OpenProject; + + await ctx.mount(''); + const frame = ctx.container.querySelector('[data-controller="backlogs--list-refresh"]')!; + const reload = vi.fn(); + (frame as unknown as { reload:() => void }).reload = reload; + + frame.remove(); + await ctx.nextFrame(); + + resolveContext({ services: { halEvents: { aggregated$ } } }); + await ctx.nextFrame(); + + expect(aggregated$).not.toHaveBeenCalled(); + + events$.next([{ eventType: 'updated' }]); + + expect(reload).not.toHaveBeenCalled(); + }); + + it('ignores stale connect promises after the frame reconnects', async () => { + let resolveFirstContext!:(context:unknown) => void; + let resolveSecondContext!:(context:unknown) => void; + const firstContextPromise = new Promise((resolve) => { resolveFirstContext = resolve; }); + const secondContextPromise = new Promise((resolve) => { resolveSecondContext = resolve; }); + const getPluginContext = vi.fn() + .mockReturnValueOnce(firstContextPromise) + .mockReturnValueOnce(secondContextPromise); + + window.OpenProject = { + getPluginContext, + } as unknown as typeof window.OpenProject; + + await ctx.mount(''); + const frame = ctx.container.querySelector('[data-controller="backlogs--list-refresh"]')!; + const reload = vi.fn(); + (frame as unknown as { reload:() => void }).reload = reload; + + await waitFor(() => { expect(getPluginContext).toHaveBeenCalledTimes(1); }); + + frame.remove(); + await ctx.nextFrame(); + ctx.container.append(frame); + await waitFor(() => { expect(getPluginContext).toHaveBeenCalledTimes(2); }); + + resolveSecondContext({ services: { halEvents: { aggregated$ } } }); + await waitFor(() => { expect(aggregated$).toHaveBeenCalledTimes(1); }); + + resolveFirstContext({ services: { halEvents: { aggregated$ } } }); + await ctx.nextFrame(); + + expect(aggregated$).toHaveBeenCalledTimes(1); + + events$.next([{ eventType: 'updated' }]); + + expect(reload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts similarity index 63% rename from frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts rename to frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts index db868d591f5a..b95c2e6c5017 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts @@ -28,34 +28,41 @@ import { Controller } from '@hotwired/stimulus'; import { FrameElement } from '@hotwired/turbo'; -import { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; import { filter, Subscription } from 'rxjs'; -export default class BacklogsController extends Controller { - private service:HalEventsService|null = null; +// The Backlogs lists are server-rendered and updated via Turbo streams on drag +// moves, but a work package can also change outside a drag — e.g. edited in the +// split pane or by the Angular layer. Those edits emit HAL events rather than a +// Turbo stream targeting this frame, so subscribe to them and reload the frame +// to keep the cards (and sprint point totals) in sync. +export default class ListRefreshController extends Controller { private subscription:Subscription|null = null; + private currentConnectionToken?:symbol; // eslint-disable-next-line @typescript-eslint/no-misused-promises async connect() { + const connectionToken = Symbol('backlogs-list-refresh'); + this.currentConnectionToken = connectionToken; + const { services: { halEvents } } = await window.OpenProject.getPluginContext(); - this.service = halEvents; - this.subscription = this.service.aggregated$('WorkPackage') + if (!this.isCurrentConnection(connectionToken)) { + return; + } + + this.subscription = halEvents + .aggregated$('WorkPackage') .pipe(filter((events) => events.some((event) => event.eventType === 'updated'))) - .subscribe(() => { this.refreshList(); }); + .subscribe(() => { void this.element.reload(); }); } disconnect() { + this.currentConnectionToken = undefined; this.subscription?.unsubscribe(); this.subscription = null; - this.service = null; - } - - private refreshList() { - void this.listElement.reload(); } - private get listElement() { - return this.element.querySelector('#backlogs_container')!; + private isCurrentConnection(connectionToken:symbol):boolean { + return this.element.isConnected && this.currentConnectionToken === connectionToken; } } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts new file mode 100644 index 000000000000..bfe8fce375d6 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -0,0 +1,613 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */ + +import { Controller } from '@hotwired/stimulus'; + +import type { autoScrollForElements as autoScrollForElementsFn } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import type { dropTargetForElements as dropTargetForElementsFn, monitorForElements as monitorForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import type { FetchRequest as FetchRequestFn } from '@rails/request.js'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; +import type SortableListsControllerType from './sortable-lists.controller'; +import type { sortableItemData as sortableItemDataFn } from './sortable-lists/drag-and-drop'; + +describe('Sortable lists controller', () => { + const flushPromises = () => new Promise((resolve) => setTimeout(resolve)); + + let FetchRequest:typeof FetchRequestFn; + let dropTargetForElements:typeof dropTargetForElementsFn; + let monitorForElements:typeof monitorForElementsFn; + let autoScrollForElements:typeof autoScrollForElementsFn; + let SortableListsController:typeof SortableListsControllerType; + let sortableItemData:typeof sortableItemDataFn; + + let ctx:StimulusTestContext; + let fixture:HTMLElement; + let loadingIndicator:HTMLElement; + + beforeAll(async () => { + vi.doMock('@rails/request.js', () => ({ + FetchRequest: vi.fn(function FetchRequest() { + return { + perform: vi.fn(() => Promise.resolve({ ok: true })), + }; + }), + })); + + vi.doMock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({ + draggable: vi.fn(() => vi.fn()), + dropTargetForElements: vi.fn(() => vi.fn()), + monitorForElements: vi.fn(() => vi.fn()), + })); + + vi.doMock('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element', () => ({ + autoScrollForElements: vi.fn(() => vi.fn()), + })); + + ({ FetchRequest } = await import('@rails/request.js')); + ({ dropTargetForElements, monitorForElements } = await import('@atlaskit/pragmatic-drag-and-drop/element/adapter')); + ({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element')); + ({ default: SortableListsController } = await import('./sortable-lists.controller')); + ({ sortableItemData } = await import('./sortable-lists/drag-and-drop')); + }); + + function input() { + return { + altKey: false, + button: 0, + buttons: 0, + ctrlKey: false, + metaKey: false, + shiftKey: false, + clientX: 10, + clientY: 10, + pageX: 10, + pageY: 10, + }; + } + + function itemRow(id:string, { moveUrl = '/move' }:{ moveUrl?:string|null } = {}):HTMLLIElement { + const row = document.createElement('li'); + + row.setAttribute('data-sortable-lists--item-id-value', id); + row.setAttribute('data-sortable-lists--item-type-value', 'work_package'); + if (moveUrl) { + row.setAttribute('data-sortable-lists--item-move-url-value', moveUrl); + } + + return row; + } + + function renderFixture({ + acceptedType = null, + moveUrlTemplate = null, + itemMoveUrl = '/move', + }:{ + acceptedType?:string|null; + moveUrlTemplate?:string|null; + itemMoveUrl?:string|null; + } = {}) { + fixture.innerHTML = ` +
+
    +
      +
      + `; + + const [sourceList, targetList] = Array.from(fixture.querySelectorAll('[data-sortable-lists-target="list"]')); + const root = fixture.querySelector('[data-controller="sortable-lists"]')!; + + sourceList.append(itemRow('1', { moveUrl: itemMoveUrl }), itemRow('2', { moveUrl: itemMoveUrl }), itemRow('3', { moveUrl: itemMoveUrl })); + targetList.append(itemRow('4', { moveUrl: itemMoveUrl }), itemRow('5', { moveUrl: itemMoveUrl })); + + return { + root, + sourceList, + targetList, + firstSourceItem: sourceList.querySelector('[data-sortable-lists--item-id-value="1"]')!, + }; + } + + function renderScrollableFixture(values = '') { + fixture.innerHTML = ` +
      +
      +
      + `; + + return fixture.querySelector('[data-sortable-lists-target="scrollable"]')!; + } + + async function dropCurrentItemOnList(sourceElement:HTMLElement, list:HTMLElement) { + const monitorOptions = vi.mocked(monitorForElements).mock.lastCall?.[0]; + + await monitorOptions?.onDrop?.({ + source: sourcePayload( + sourceElement, + itemData( + sourceElement.getAttribute('data-sortable-lists--item-id-value')!, + 'work_package', + sourceElement.getAttribute('data-sortable-lists--item-move-url-value') ?? undefined, + ), + ), + location: { + initial: { + dropTargets: [], + input: input(), + }, + current: { + dropTargets: [ + dropTargetRecord( + list, + { + type: list.getAttribute('data-sortable-lists-list-type'), + listId: list.getAttribute('data-sortable-lists-list-id'), + }, + ), + ], + input: input(), + }, + previous: { + dropTargets: [], + }, + }, + }); + } + + function itemData(itemId = '1', type = 'work_package', moveUrl?:string) { + return sortableItemData({ itemId, moveUrl, type }); + } + + function sourcePayload(element:HTMLElement, data:Record = itemData()) { + return { + data, + dragHandle: null, + element, + }; + } + + function dropTargetRecord(element:HTMLElement, data:Record) { + return { + data, + dropEffect: 'move' as const, + element, + isActiveDueToStickiness: false, + }; + } + + function dropTargetOptionsFor(element:HTMLElement) { + return vi.mocked(dropTargetForElements).mock.calls.find(([options]) => options.element === element)?.[0]; + } + + beforeEach(async () => { + vi.clearAllMocks(); + + loadingIndicator = document.createElement('div'); + loadingIndicator.id = 'global-loading-indicator'; + loadingIndicator.hidden = true; + document.body.appendChild(loadingIndicator); + + ctx = await setupStimulusTest({ + controllers: { + 'sortable-lists': SortableListsController, + }, + }); + fixture = ctx.container; + }); + + afterEach(() => { + ctx.dispose(); + loadingIndicator.remove(); + }); + + it('does not turn a list-only drop onto the source list into an append move', async () => { + const { sourceList, firstSourceItem } = renderFixture(); + + await ctx.nextFrame(); + await dropCurrentItemOnList(firstSourceItem, sourceList); + + expect(FetchRequest).not.toHaveBeenCalled(); + }); + + it('ignores drops that belong to another sortable lists root', async () => { + fixture.innerHTML = ` +
      +
        +
      • +
      +
      +
      +
        +
      • +
      • +
      +
      + `; + + await ctx.nextFrame(); + + const firstRootMonitor = vi.mocked(monitorForElements).mock.calls[0]?.[0]; + const secondRootSource = fixture.querySelector('[data-sortable-lists--item-id-value="10"]')!; + const secondRootTarget = fixture.querySelector('[data-sortable-lists--item-id-value="11"]')!; + + await firstRootMonitor?.onDrop?.({ + source: sourcePayload(secondRootSource, itemData('10', 'work_package', '/move-10')), + location: { + initial: { + dropTargets: [], + input: input(), + }, + current: { + dropTargets: [ + dropTargetRecord(secondRootTarget, itemData('11', 'work_package')), + ], + input: input(), + }, + previous: { + dropTargets: [], + }, + }, + }); + + expect(FetchRequest).not.toHaveBeenCalled(); + }); + + it('ignores drops whose source type does not match the root accepted type', async () => { + const { targetList, firstSourceItem } = renderFixture({ acceptedType: 'work_package' }); + const targetItem = targetList.querySelector('[data-sortable-lists--item-id-value="4"]')!; + + await ctx.nextFrame(); + + await vi.mocked(monitorForElements).mock.lastCall?.[0].onDrop?.({ + source: sourcePayload(firstSourceItem, itemData('1', 'meeting_agenda_item', '/move')), + location: { + initial: { + dropTargets: [], + input: input(), + }, + current: { + dropTargets: [ + dropTargetRecord(targetItem, itemData('4', 'work_package')), + ], + input: input(), + }, + previous: { + dropTargets: [], + }, + }, + }); + + expect(FetchRequest).not.toHaveBeenCalled(); + }); + + it('appends the item when list-only dropping onto another list', async () => { + const { targetList, firstSourceItem } = renderFixture(); + + await ctx.nextFrame(); + await dropCurrentItemOnList(firstSourceItem, targetList); + + expect(FetchRequest).toHaveBeenCalledOnce(); + + const options = vi.mocked(FetchRequest).mock.lastCall?.[2] as { body:FormData }; + + expect(options.body.get('list_type')).toEqual('sprint'); + expect(options.body.get('list_id')).toEqual('1'); + expect(options.body.get('prev_id')).toEqual('5'); + }); + + it('builds the move URL from the controller URI template', async () => { + const { targetList, firstSourceItem } = renderFixture({ + moveUrlTemplate: '/projects/demo/backlogs/work_packages/{id}/move', + itemMoveUrl: null, + }); + + await ctx.nextFrame(); + await dropCurrentItemOnList(firstSourceItem, targetList); + + expect(FetchRequest).toHaveBeenCalledWith( + 'put', + '/projects/demo/backlogs/work_packages/1/move', + expect.any(Object), + ); + }); + + it('uses the sortable item move URL before the controller URI template', async () => { + const { targetList, firstSourceItem } = renderFixture({ + moveUrlTemplate: '/projects/demo/backlogs/work_packages/{id}/move', + itemMoveUrl: '/custom/move', + }); + + await ctx.nextFrame(); + await dropCurrentItemOnList(firstSourceItem, targetList); + + expect(FetchRequest).toHaveBeenCalledWith( + 'put', + '/custom/move', + expect.any(Object), + ); + }); + + it('falls back to the sortable item move URL while item-specific endpoints still exist', async () => { + const { targetList, firstSourceItem } = renderFixture(); + + await ctx.nextFrame(); + await dropCurrentItemOnList(firstSourceItem, targetList); + + expect(FetchRequest).toHaveBeenCalledWith( + 'put', + '/move', + expect.any(Object), + ); + }); + + it('marks the sortable lists root and global loading indicator while moving an item', async () => { + let resolveMove:(response:{ ok:boolean }) => void; + + vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { + return { + perform: vi.fn(() => new Promise<{ ok:boolean }>((resolve) => { + resolveMove = resolve; + })), + }; + }); + + const { root, targetList, firstSourceItem } = renderFixture(); + + await ctx.nextFrame(); + await dropCurrentItemOnList(firstSourceItem, targetList); + + expect(root.dataset.sortableListsMoving).toEqual('true'); + expect(root.getAttribute('aria-busy')).toEqual('true'); + expect(loadingIndicator.hidden).toBe(false); + + resolveMove!({ ok: true }); + await flushPromises(); + + expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false); + expect(root.hasAttribute('aria-busy')).toBe(false); + expect(loadingIndicator.hidden).toBe(true); + }); + + it('rejects new sortable-list drags and drops while a move is pending', async () => { + let resolveMove:(response:{ ok:boolean }) => void; + + vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { + return { + perform: vi.fn(() => new Promise<{ ok:boolean }>((resolve) => { + resolveMove = resolve; + })), + }; + }); + + const { targetList, firstSourceItem } = renderFixture(); + + await ctx.nextFrame(); + await dropCurrentItemOnList(firstSourceItem, targetList); + + expect(vi.mocked(monitorForElements).mock.lastCall?.[0].canMonitor?.({ + source: sourcePayload(firstSourceItem), + initial: {} as never, + })).toBe(false); + expect(dropTargetOptionsFor(targetList)?.canDrop?.({ + element: targetList, + input: input(), + source: sourcePayload(firstSourceItem), + })).toBe(false); + + resolveMove!({ ok: true }); + await flushPromises(); + }); + + it('dispatches an error toast when the move request rejects', async () => { + const toastEvents:CustomEvent[] = []; + const onToast = (event:Event) => toastEvents.push(event as CustomEvent); + + window.addEventListener('op:toasters:add', onToast); + vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { + return { + perform: vi.fn(() => Promise.reject(new Error('Network failure'))), + }; + }); + + const { root, targetList, firstSourceItem } = renderFixture(); + + await ctx.nextFrame(); + await dropCurrentItemOnList(firstSourceItem, targetList); + await flushPromises(); + + expect(toastEvents).toHaveLength(1); + expect(toastEvents[0].detail).toEqual(expect.objectContaining({ + message: expect.any(String), + type: 'error', + })); + expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false); + expect(loadingIndicator.hidden).toBe(true); + + window.removeEventListener('op:toasters:add', onToast); + }); + + it('registers Backlogs lists as drop targets', async () => { + const { sourceList, targetList } = renderFixture(); + + await ctx.nextFrame(); + + expect(dropTargetForElements).toHaveBeenCalledWith(expect.objectContaining({ + element: sourceList, + })); + expect(dropTargetForElements).toHaveBeenCalledWith(expect.objectContaining({ + element: targetList, + })); + }); + + it('accepts item drops when the source type matches the root accepted type', async () => { + const { targetList, firstSourceItem } = renderFixture({ acceptedType: 'work_package' }); + + await ctx.nextFrame(); + + expect(dropTargetOptionsFor(targetList)?.canDrop?.({ + element: targetList, + input: input(), + source: sourcePayload(firstSourceItem, itemData('1', 'work_package')), + })).toBe(true); + }); + + it('applies the accepted item type to every list target in the controller root', async () => { + const { sourceList, targetList, firstSourceItem } = renderFixture({ acceptedType: 'work_package' }); + + await ctx.nextFrame(); + + for (const list of [sourceList, targetList]) { + expect(dropTargetOptionsFor(list)?.canDrop?.({ + element: list, + input: input(), + source: sourcePayload(firstSourceItem, itemData('1', 'meeting_agenda_item')), + })).toBe(false); + } + }); + + it('rejects item drops when the source type does not match the root accepted type', async () => { + const { targetList, firstSourceItem } = renderFixture({ acceptedType: 'work_package' }); + + await ctx.nextFrame(); + + expect(dropTargetOptionsFor(targetList)?.canDrop?.({ + element: targetList, + input: input(), + source: sourcePayload(firstSourceItem, itemData('1', 'meeting_agenda_item')), + })).toBe(false); + }); + + it('registers scrollable targets for vertical sortable list auto-scrolling', async () => { + const scrollable = renderScrollableFixture(); + + await ctx.nextFrame(); + + expect(autoScrollForElements).toHaveBeenCalledWith(expect.objectContaining({ + element: scrollable, + })); + + const options = vi.mocked(autoScrollForElements).mock.lastCall?.[0]; + + expect(options?.canScroll?.({ + element: scrollable, + input: input(), + source: sourcePayload(itemRow('1')), + })).toBe(true); + expect(options?.canScroll?.({ + element: scrollable, + input: input(), + source: sourcePayload(document.createElement('div'), { type: 'unrelated' }), + })).toBe(false); + expect(options?.getAllowedAxis?.({ + element: scrollable, + input: input(), + source: sourcePayload(itemRow('1')), + })).toEqual('vertical'); + expect(options?.getConfiguration?.({ + element: scrollable, + input: input(), + source: sourcePayload(itemRow('1')), + })).toEqual({ maxScrollSpeed: 'standard' }); + }); + + it('cleans up scrollable target auto-scrolling on disconnect', async () => { + const scrollableCleanup = vi.fn(); + vi.mocked(autoScrollForElements).mockReturnValue(scrollableCleanup); + + renderScrollableFixture(); + + await ctx.nextFrame(); + + fixture.innerHTML = ''; + await ctx.nextFrame(); + + expect(scrollableCleanup).toHaveBeenCalledOnce(); + }); + + describe('Turbo morph coordination', () => { + const refresh = vi.fn(); + + function renderListWithItem() { + class ItemStub extends Controller { + refresh = refresh; + } + ctx.application.register('sortable-lists--item', ItemStub); + + fixture.innerHTML = ` +
      +
        +
      • +
      +
      + `; + } + + it('refreshes only the morphed sortable item from a single root-level listener', async () => { + renderListWithItem(); + await ctx.nextFrame(); + + const list = fixture.querySelector('[data-sortable-lists-target="list"]')!; + const item = fixture.querySelector('[data-sortable-lists--item-id-value="1"]')!; + + // A morph on a non-item element under the root is ignored. + list.dispatchEvent(new CustomEvent('turbo:morph-element', { bubbles: true })); + expect(refresh).not.toHaveBeenCalled(); + + // A morph on the item refreshes that item exactly once. + item.dispatchEvent(new CustomEvent('turbo:morph-element', { bubbles: true })); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it('stops handling morph events once the root disconnects', async () => { + renderListWithItem(); + await ctx.nextFrame(); + + const item = fixture.querySelector('[data-sortable-lists--item-id-value="1"]')!; + + fixture.innerHTML = ''; + await ctx.nextFrame(); + + item.dispatchEvent(new CustomEvent('turbo:morph-element', { bubbles: true })); + expect(refresh).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts new file mode 100644 index 000000000000..d613adec548e --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -0,0 +1,321 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { + dropTargetForElements, + monitorForElements, + type ElementEventPayloadMap, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { Controller } from '@hotwired/stimulus'; +import { FetchRequest } from '@rails/request.js'; +import { debugLog } from 'core-app/shared/helpers/debug_output'; +import { withLoadingIndicator } from 'core-stimulus/helpers/request-helpers'; +import URI from 'urijs'; +import 'urijs/src/URITemplate'; +import { + acceptsSortableItemType, + buildMoveFormData, + isSortableItemData, + isSourceListTarget, + resolveFallbackDropTarget, + resolveListAppendPreviousItemId, + resolveListData, + resolvePreviousSortableItemId, + sortableItemSelector, + sortableListsMovingAttribute, + type SortableItemData, + type SortableListData, +} from './sortable-lists/drag-and-drop'; + +type CleanupFn = () => void; +type ElementDropPayload = ElementEventPayloadMap['onDrop']; +type AutoScrollAllowedAxis = 'vertical'|'horizontal'|'all'; +type AutoScrollMaxScrollSpeed = 'standard'|'fast'; + +const allowedAxes = new Set(['vertical', 'horizontal', 'all']); +const maxScrollSpeeds = new Set(['standard', 'fast']); + +export default class SortableListsController extends Controller { + static targets = ['list', 'scrollable']; + + static values = { + acceptedType: String, + moveUrlTemplate: String, + allowedAxis: { type: String, default: 'vertical' }, + maxScrollSpeed: { type: String, default: 'standard' }, + }; + + declare readonly listTargets:HTMLElement[]; + declare readonly scrollableTargets:HTMLElement[]; + + declare readonly acceptedTypeValue:string; + declare readonly hasAcceptedTypeValue:boolean; + declare readonly moveUrlTemplateValue:string; + declare readonly hasMoveUrlTemplateValue:boolean; + declare readonly allowedAxisValue:string; + declare readonly maxScrollSpeedValue:string; + + private monitorCleanupFn?:CleanupFn; + private listCleanupFns = new Map(); + private scrollableCleanupFns = new Map(); + private readonly handleMorphBound = this.handleMorph.bind(this); + + connect():void { + this.monitorCleanupFn = monitorForElements({ + canMonitor: ({ source }) => !this.moving && isSortableItemData(source.data), + onDrop: (args) => { + void this.handleDrop(args); + }, + }); + document.addEventListener('turbo:morph-element', this.handleMorphBound); + } + + disconnect():void { + this.monitorCleanupFn?.(); + this.monitorCleanupFn = undefined; + document.removeEventListener('turbo:morph-element', this.handleMorphBound); + this.listCleanupFns.forEach((cleanup) => cleanup()); + this.listCleanupFns.clear(); + this.scrollableCleanupFns.forEach((cleanup) => cleanup()); + this.scrollableCleanupFns.clear(); + } + + listTargetConnected(element:HTMLElement):void { + const cleanup = dropTargetForElements({ + element, + canDrop: ({ source }) => !this.moving && isSortableItemData(source.data) && acceptsSortableItemType({ + acceptedType: this.acceptedType, + type: source.data.type, + }), + getData: () => resolveListData(element) ?? {}, + getIsSticky: () => false, + }); + + this.listCleanupFns.set(element, cleanup); + } + + listTargetDisconnected(element:HTMLElement):void { + this.listCleanupFns.get(element)?.(); + this.listCleanupFns.delete(element); + } + + scrollableTargetConnected(element:HTMLElement):void { + const cleanup = autoScrollForElements({ + element, + canScroll: ({ source }) => isSortableItemData(source.data), + getAllowedAxis: () => this.allowedAxis, + getConfiguration: () => ({ maxScrollSpeed: this.maxScrollSpeed }), + }); + + this.scrollableCleanupFns.set(element, cleanup); + } + + scrollableTargetDisconnected(element:HTMLElement):void { + this.scrollableCleanupFns.get(element)?.(); + this.scrollableCleanupFns.delete(element); + } + + // A Turbo morph can strip the Pragmatic DnD attributes/listeners an item + // controller applied to its row. One root-level listener refreshes the + // affected item, instead of every item registering its own document listener. + private handleMorph(event:Event):void { + const target = event.target; + + if (!(target instanceof HTMLElement) || !target.matches(sortableItemSelector) || !this.element.contains(target)) { + return; + } + + const controller = this.application.getControllerForElementAndIdentifier(target, 'sortable-lists--item'); + (controller as { refresh?:() => void }|null)?.refresh?.(); + } + + private get allowedAxis():AutoScrollAllowedAxis { + return allowedAxes.has(this.allowedAxisValue) ? this.allowedAxisValue as AutoScrollAllowedAxis : 'vertical'; + } + + private get acceptedType():string|null { + // The accepted type is scoped to this controller instance, so every list target + // inside one sortable-lists root accepts the same sortable item type. + return this.hasAcceptedTypeValue ? this.acceptedTypeValue : null; + } + + private get maxScrollSpeed():AutoScrollMaxScrollSpeed { + return maxScrollSpeeds.has(this.maxScrollSpeedValue) ? this.maxScrollSpeedValue as AutoScrollMaxScrollSpeed : 'standard'; + } + + private get moving():boolean { + return this.element.hasAttribute(sortableListsMovingAttribute); + } + + private async handleDrop({ location, source }:ElementDropPayload) { + if (this.moving) { + return; + } + + if (!isSortableItemData(source.data) || !(source.element instanceof HTMLElement)) { + return; + } + + if ( + !this.element.contains(source.element) || + !acceptsSortableItemType({ + acceptedType: this.acceptedType, + type: source.data.type, + }) + ) { + return; + } + + const moveUrl = this.resolveMoveUrl(source.data); + if (!moveUrl) { + return; + } + + const targetItem = location.current.dropTargets.find(({ data, element }) => ( + isSortableItemData(data) && element instanceof HTMLElement && this.element.contains(element) + )); + const targetList = location.current.dropTargets.find(({ element }) => ( + element instanceof HTMLElement && this.element.contains(element) + )); + const fallbackTarget = location.current.dropTargets.length === 0 + ? resolveFallbackDropTarget({ + input: location.current.input, + root: this.element, + sourceElement: source.element, + }) + : null; + const fallbackItem = fallbackTarget?.isItem ? fallbackTarget : null; + const resolvedTargetItem = targetItem ?? fallbackItem; + const targetElement = resolvedTargetItem?.element ?? targetList?.element ?? fallbackTarget?.element; + + if (!(targetElement instanceof HTMLElement)) { + return; + } + + const listData = resolveListData(targetElement); + if (!listData) { + return; + } + + if (!resolvedTargetItem && isSourceListTarget({ sourceElement: source.element, targetElement })) { + return; + } + + const previousItemId = resolvedTargetItem?.element instanceof HTMLElement + ? resolvePreviousSortableItemId({ + sourceItemId: source.data.itemId, + targetItem: resolvedTargetItem.element, + closestEdge: extractClosestEdge(resolvedTargetItem.data), + }) + : resolveListAppendPreviousItemId({ + sourceItemId: source.data.itemId, + list: targetElement, + }); + + await this.moveItem({ + listData, + previousItemId, + sourceData: source.data, + }); + } + + private resolveMoveUrl(data:{ itemId:string; moveUrl?:string }):string|null { + if (data.moveUrl) { + return data.moveUrl; + } + + if (this.hasMoveUrlTemplateValue) { + return URI.expand?.(this.moveUrlTemplateValue, { id: data.itemId }).toString() ?? null; + } + + return null; + } + + private async moveItem({ + listData, + previousItemId, + sourceData, + }:{ + listData:SortableListData; + previousItemId:string|null; + sourceData:SortableItemData; + }):Promise { + const moveUrl = this.resolveMoveUrl(sourceData); + if (!moveUrl) { + return; + } + + const request = new FetchRequest( + 'put', + moveUrl, + { + body: buildMoveFormData({ + listId: listData.listId, + previousItemId, + type: listData.type, + }), + responseKind: 'turbo-stream', + }, + ); + + this.setMoving(true); + try { + const response = await withLoadingIndicator(request.perform()); + + if (!response.ok) { + debugLog(`Failed to move sortable list item: ${response.statusCode}`); + } + } catch (error) { + debugLog('Failed to move sortable list item due to request error', error); + this.dispatchErrorToast(); + } finally { + this.setMoving(false); + } + } + + private setMoving(moving:boolean):void { + if (moving) { + this.element.setAttribute(sortableListsMovingAttribute, 'true'); + this.element.setAttribute('aria-busy', 'true'); + } else { + this.element.removeAttribute(sortableListsMovingAttribute); + this.element.removeAttribute('aria-busy'); + } + } + + private dispatchErrorToast():void { + window.dispatchEvent(new CustomEvent('op:toasters:add', { + detail: { + message: I18n.t('js.error.internal'), + type: 'error', + }, + })); + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts new file mode 100644 index 000000000000..a792dc0398ed --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -0,0 +1,439 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { + acceptsSortableItemType, + buildMoveFormData, + isSortableItemData, + isSortableListData, + resolveFallbackDropTarget, + resolveItemType, + resolveListAppendPreviousItemId, + resolveListData, + resolvePreviousSortableItemId, + sortableItemData, + sortableListData, +} from './drag-and-drop'; + +describe('sortable lists drag and drop helpers', () => { + function itemRow(id:string):HTMLLIElement { + const row = document.createElement('li'); + const item = document.createElement('article'); + + row.setAttribute('data-sortable-lists--item-id-value', id); + row.appendChild(item); + + return row; + } + + function showMoreRow(previousItemId = 'hidden-item'):HTMLLIElement { + const row = document.createElement('li'); + + row.setAttribute('data-sortable-lists-prev-item-id', previousItemId); + + return row; + } + + describe('isSortableItemData', () => { + it('accepts backlogs item data', () => { + expect(isSortableItemData(sortableItemData({ type: 'work_package', itemId: '42' }))).toBe(true); + }); + + it('rejects lookalike data from another drag source', () => { + expect(isSortableItemData({ type: 'work_package', itemId: '42' })).toBe(false); + }); + + it('rejects data without an item id', () => { + expect(isSortableItemData({ type: 'work_package' })).toBe(false); + }); + + it('rejects data with a blank item id', () => { + expect(isSortableItemData(sortableItemData({ type: 'work_package', itemId: '' }))).toBe(false); + }); + }); + + describe('isSortableListData', () => { + it('accepts sortable list data', () => { + expect(isSortableListData(sortableListData({ type: 'sprint', listId: '42' }))).toBe(true); + }); + + it('rejects lookalike data from another drop target', () => { + expect(isSortableListData({ type: 'sprint', listId: '42' })).toBe(false); + }); + }); + + describe('sortableItemData', () => { + it('uses the item type as the public source type', () => { + const data = sortableItemData({ type: 'work_package', itemId: '42' }); + + expect(data.type).toEqual('work_package'); + expect(data.itemId).toEqual('42'); + expect(isSortableItemData(data)).toBe(true); + }); + + it('includes a move URL when the sortable item has one', () => { + expect(sortableItemData({ type: 'work_package', itemId: '42', moveUrl: '/move' })).toEqual(expect.objectContaining({ + itemId: '42', + moveUrl: '/move', + type: 'work_package', + })); + }); + }); + + describe('acceptsSortableItemType', () => { + it('allows drops when the controller has no accepted type filter', () => { + expect(acceptsSortableItemType({ acceptedType: null, type: 'work_package' })).toBe(true); + }); + + it('allows drops when the source type matches the accepted type', () => { + expect(acceptsSortableItemType({ acceptedType: 'work_package', type: 'work_package' })).toBe(true); + }); + + it('rejects drops when the source type does not match the accepted type', () => { + expect(acceptsSortableItemType({ acceptedType: 'work_package', type: 'meeting_agenda_item' })).toBe(false); + }); + }); + + describe('resolveItemType', () => { + it('reads the item type Stimulus value', () => { + const item = itemRow('1'); + + item.setAttribute('data-sortable-lists--item-type-value', 'work_package'); + + expect(resolveItemType(item)).toEqual('work_package'); + }); + + it('uses a generic item type when no item type value is present', () => { + expect(resolveItemType(itemRow('1'))).toEqual('item'); + }); + }); + + describe('buildMoveFormData', () => { + it('serializes list data and previous item id for the move endpoint', () => { + const data = buildMoveFormData({ type: 'backlog_bucket', listId: '7', previousItemId: '12' }); + + expect(data.get('list_type')).toEqual('backlog_bucket'); + expect(data.get('list_id')).toEqual('7'); + expect(data.get('prev_id')).toEqual('12'); + }); + + it('serializes a top-of-list move as an empty previous item id', () => { + const data = buildMoveFormData({ type: 'inbox', listId: null, previousItemId: null }); + + expect(data.get('list_type')).toEqual('inbox'); + expect(data.get('list_id')).toEqual(''); + expect(data.get('prev_id')).toEqual(''); + }); + }); + + describe('resolvePreviousSortableItemId', () => { + it('uses the target item as previous item when dropping on the bottom edge', () => { + const target = itemRow('3').querySelector('article')!; + + expect(resolvePreviousSortableItemId({ sourceItemId: '1', targetItem: target, closestEdge: 'bottom' })).toEqual('3'); + }); + + it('uses the row item as previous item when the drop target is the row', () => { + const target = itemRow('3'); + + expect(resolvePreviousSortableItemId({ sourceItemId: '1', targetItem: target, closestEdge: 'bottom' })).toEqual('3'); + }); + + it('uses the previous row item when dropping on the top edge', () => { + const list = document.createElement('ul'); + const first = itemRow('1'); + const targetRow = itemRow('3'); + const target = targetRow.querySelector('article')!; + + list.append(first, targetRow); + + expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: 'top' })).toEqual('1'); + }); + + it('uses the previous row item when dropping on the top edge of a row target', () => { + const list = document.createElement('ul'); + const first = itemRow('1'); + const targetRow = itemRow('3'); + + list.append(first, targetRow); + + expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: targetRow, closestEdge: 'top' })).toEqual('1'); + }); + + it('treats a missing closest edge as dropping before the target item', () => { + const list = document.createElement('ul'); + const first = itemRow('1'); + const targetRow = itemRow('3'); + const target = targetRow.querySelector('article')!; + + list.append(first, targetRow); + + expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: null })).toEqual('1'); + }); + + it('uses a truncation marker when dropping before a tail item', () => { + const list = document.createElement('ul'); + const first = itemRow('1'); + const targetRow = itemRow('6'); + const target = targetRow.querySelector('article')!; + + list.append(first, showMoreRow('5'), targetRow); + + expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: 'top' })).toEqual('5'); + }); + + it('skips the source item and uses a preceding truncation marker when resolving the previous item', () => { + const list = document.createElement('ul'); + const first = itemRow('1'); + const source = itemRow('2'); + const targetRow = itemRow('3'); + const target = targetRow.querySelector('article')!; + + list.append(first, showMoreRow(), source, targetRow); + + expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: 'top' })).toEqual('hidden-item'); + }); + + it('returns null when dropping before the first item', () => { + const target = itemRow('1').querySelector('article')!; + + expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: 'top' })).toBeNull(); + }); + }); + + describe('resolveListData', () => { + it('reads the nearest list type and id', () => { + const list = document.createElement('ul'); + const row = itemRow('1'); + const item = row.querySelector('article')!; + + list.setAttribute('data-sortable-lists-target', 'list'); + list.setAttribute('data-sortable-lists-list-type', 'sprint'); + list.setAttribute('data-sortable-lists-list-id', '12'); + list.appendChild(row); + + expect(resolveListData(item)).toEqual(expect.objectContaining({ type: 'sprint', listId: '12' })); + }); + + it('uses null as the list id for lists without an id', () => { + const list = document.createElement('ul'); + + list.setAttribute('data-sortable-lists-target', 'list'); + list.setAttribute('data-sortable-lists-list-type', 'inbox'); + + expect(resolveListData(list)).toEqual(expect.objectContaining({ type: 'inbox', listId: null })); + }); + }); + + describe('resolveFallbackDropTarget', () => { + function input({ clientX = 10, clientY = 10 } = {}) { + return { + altKey: false, + button: 0, + buttons: 0, + ctrlKey: false, + metaKey: false, + shiftKey: false, + clientX, + clientY, + pageX: clientX, + pageY: clientY, + }; + } + + function rect():DOMRect { + return { + top: 0, + bottom: 100, + left: 0, + right: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }; + } + + function stubElementFromPoint(element:Element) { + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => element), + }); + } + + function stubElementsFromPoint(elements:Element[]) { + Object.defineProperty(document, 'elementsFromPoint', { + configurable: true, + value: vi.fn(() => elements), + }); + } + + afterEach(() => { + vi.restoreAllMocks(); + document.body.replaceChildren(); + }); + + it('resolves an item at the drop coordinates', () => { + const root = document.createElement('div'); + const list = document.createElement('div'); + const row = itemRow('42'); + const item = row.querySelector('article')!; + + list.setAttribute('data-sortable-lists-target', 'list'); + list.setAttribute('data-sortable-lists-list-type', 'backlog_bucket'); + list.setAttribute('data-sortable-lists-list-id', '7'); + list.appendChild(row); + root.appendChild(list); + document.body.appendChild(root); + stubElementFromPoint(item); + vi.spyOn(row, 'getBoundingClientRect').mockReturnValue(rect()); + + const target = resolveFallbackDropTarget({ + input: input({ clientY: 90 }), + root, + }); + + expect(target?.element).toBe(row); + expect(target?.isItem).toBe(true); + expect(target?.data.itemId).toEqual('42'); + expect(extractClosestEdge(target!.data)).toEqual('bottom'); + }); + + it('skips drag overlay elements and resolves the underlying item', () => { + const root = document.createElement('div'); + const list = document.createElement('div'); + const row = itemRow('42'); + const item = row.querySelector('article')!; + const dragOverlay = document.createElement('div'); + + list.setAttribute('data-sortable-lists-target', 'list'); + list.setAttribute('data-sortable-lists-list-type', 'backlog_bucket'); + list.setAttribute('data-sortable-lists-list-id', '7'); + list.appendChild(row); + root.appendChild(list); + document.body.append(root, dragOverlay); + stubElementFromPoint(dragOverlay); + stubElementsFromPoint([dragOverlay, item]); + vi.spyOn(row, 'getBoundingClientRect').mockReturnValue(rect()); + + const target = resolveFallbackDropTarget({ + input: input({ clientY: 90 }), + root, + }); + + expect(target?.element).toBe(row); + expect(target?.isItem).toBe(true); + expect(target?.data.itemId).toEqual('42'); + }); + + it('resolves a list at the drop coordinates when no item is under the pointer', () => { + const root = document.createElement('div'); + const list = document.createElement('div'); + const header = document.createElement('div'); + + list.setAttribute('data-sortable-lists-target', 'list'); + list.setAttribute('data-sortable-lists-list-type', 'backlog_bucket'); + list.setAttribute('data-sortable-lists-list-id', '7'); + list.appendChild(header); + root.appendChild(list); + document.body.appendChild(root); + stubElementFromPoint(header); + + const target = resolveFallbackDropTarget({ + input: input(), + root, + }); + + expect(target?.element).toBe(list); + expect(target?.isItem).toBe(false); + expect(isSortableListData(target!.data)).toBe(true); + expect(target?.data.type).toEqual('backlog_bucket'); + expect(target?.data.listId).toEqual('7'); + }); + + it('resolves the containing list instead of the dragged source item', () => { + const root = document.createElement('div'); + const list = document.createElement('div'); + const row = itemRow('42'); + const item = row.querySelector('article')!; + + list.setAttribute('data-sortable-lists-target', 'list'); + list.setAttribute('data-sortable-lists-list-type', 'backlog_bucket'); + list.setAttribute('data-sortable-lists-list-id', '7'); + list.appendChild(row); + root.appendChild(list); + document.body.appendChild(root); + stubElementFromPoint(item); + + const target = resolveFallbackDropTarget({ + input: input(), + root, + sourceElement: row, + }); + + expect(target?.element).toBe(list); + expect(target?.isItem).toBe(false); + expect(isSortableListData(target!.data)).toBe(true); + expect(target?.data.type).toEqual('backlog_bucket'); + expect(target?.data.listId).toEqual('7'); + }); + + it('returns null when the drop coordinates are outside the backlogs root', () => { + const root = document.createElement('div'); + const outside = document.createElement('div'); + + document.body.append(root, outside); + stubElementFromPoint(outside); + + expect(resolveFallbackDropTarget({ + input: input(), + root, + })).toBeNull(); + }); + }); + + describe('resolveListAppendPreviousItemId', () => { + it('returns the last item in a list while skipping the source and truncation marker rows', () => { + const list = document.createElement('ul'); + + list.append(itemRow('1'), showMoreRow(), itemRow('2'), itemRow('3')); + + expect(resolveListAppendPreviousItemId({ sourceItemId: '3', list })).toEqual('2'); + }); + + it('returns null when the list has no other items', () => { + const list = document.createElement('ul'); + + list.append(itemRow('1')); + + expect(resolveListAppendPreviousItemId({ sourceItemId: '1', list })).toBeNull(); + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts new file mode 100644 index 000000000000..9f5362bb58e7 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -0,0 +1,306 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { + attachClosestEdge, + type Edge, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { type Input } from '@atlaskit/pragmatic-drag-and-drop/types'; + +const sortableItemDataKey = Symbol('sortable-list-item'); +const sortableListDataKey = Symbol('sortable-list'); + +// Sortable lists use a DOM contract shared by the root and item controllers: +// the root has data-controller~="sortable-lists"; lists are root targets with +// data-sortable-lists-list-* metadata; items expose sortable-lists--item values; +// sparse non-item rows may expose data-sortable-lists-prev-item-id. +export const sortableListsMovingAttribute = 'data-sortable-lists-moving'; +export const sortableListsRootSelector = '[data-controller~="sortable-lists"]'; +export const sortableItemSelector = '[data-sortable-lists--item-id-value]'; +export const sortableListSelector = '[data-sortable-lists-target~="list"]'; +export const sortablePreviousItemIdAttribute = 'data-sortable-lists-prev-item-id'; + +export interface SortableItemData extends Record { + [sortableItemDataKey]:true; + type:string; + itemId:string; + moveUrl?:string; +} + +export interface SortableListData extends Record { + [sortableListDataKey]:true; + type:string; + listId:string|null; +} + +export interface FallbackDropTarget { + element:HTMLElement; + data:Record; + isItem:boolean; +} + +export function isSortableItemData(data:Record):data is SortableItemData { + return data[sortableItemDataKey] === true + && typeof data.type === 'string' + && data.type.length > 0 + && typeof data.itemId === 'string' + && data.itemId.length > 0; +} + +export function isSortableListData(data:Record):data is SortableListData { + return data[sortableListDataKey] === true + && typeof data.type === 'string' + && data.type.length > 0 + && (typeof data.listId === 'string' || data.listId === null); +} + +export function sortableItemData({ + type, + itemId, + moveUrl, +}:{ + type:string; + itemId:string; + moveUrl?:string; +}):SortableItemData { + return { + [sortableItemDataKey]: true, + type, + itemId, + ...(moveUrl ? { moveUrl } : {}), + }; +} + +export function sortableListData({ + type, + listId, +}:{ + type:string; + listId:string|null; +}):SortableListData { + return { + [sortableListDataKey]: true, + type, + listId, + }; +} + +export function buildMoveFormData({ + listId, + previousItemId, + type, +}:{ + listId:string|null; + previousItemId:string|null; + type:string; +}):FormData { + const data = new FormData(); + + data.append('list_type', type); + data.append('list_id', listId ?? ''); + data.append('prev_id', previousItemId ?? ''); + + return data; +} + +export function resolveItemId(element:Element):string|null { + return element.getAttribute('data-sortable-lists--item-id-value'); +} + +export function resolveItemType(element:Element):string { + return element.getAttribute('data-sortable-lists--item-type-value') ?? 'item'; +} + +export function resolveListData(element:Element):SortableListData|null { + const list = element.closest(sortableListSelector); + + if (!list) { + return null; + } + + const type = list.getAttribute('data-sortable-lists-list-type'); + + if (!type) { + return null; + } + + return sortableListData({ + type, + listId: list.getAttribute('data-sortable-lists-list-id'), + }); +} + +export function acceptsSortableItemType({ + acceptedType, + type, +}:{ + acceptedType:string|null; + type:string; +}):boolean { + return acceptedType === null || acceptedType === type; +} + +export function isSourceListTarget({ + sourceElement, + targetElement, +}:{ + sourceElement:Element; + targetElement:Element; +}):boolean { + return sourceElement.closest(sortableListSelector) === targetElement; +} + +function resolveItemElement(element:Element):HTMLElement|null { + if (element instanceof HTMLElement && element.matches(sortableItemSelector)) { + return element; + } + + return element.closest(sortableItemSelector) ?? + element.querySelector(sortableItemSelector); +} + +function resolvePreviousItemId(element:Element):string|null { + const item = resolveItemElement(element); + + // Non-item rows, such as truncated "show more" rows, can mark the last + // omitted item so position resolution remains correct in sparse lists. + return item ? resolveItemId(item) : element.getAttribute(sortablePreviousItemIdAttribute); +} + +function elementsFromPoint(document:Document, clientX:number, clientY:number):Element[] { + const elements = document.elementsFromPoint?.(clientX, clientY) ?? []; + const element = document.elementFromPoint(clientX, clientY); + + if (element && !elements.includes(element)) { + return [...elements, element]; + } + + return elements; +} + +export function resolveFallbackDropTarget({ + input, + root, + sourceElement, +}:{ + input:Input; + root:HTMLElement; + sourceElement?:HTMLElement; +}):FallbackDropTarget|null { + const elementsAtPoint = elementsFromPoint(root.ownerDocument, input.clientX, input.clientY); + + for (const elementAtPoint of elementsAtPoint) { + if (!(elementAtPoint instanceof HTMLElement) || !root.contains(elementAtPoint)) { + continue; + } + + const item = resolveItemElement(elementAtPoint); + if (item && item !== sourceElement && root.contains(item)) { + const itemId = resolveItemId(item); + + if (itemId) { + return { + element: item, + data: attachClosestEdge(sortableItemData({ itemId, type: resolveItemType(item) }), { + element: item, + input, + allowedEdges: ['top', 'bottom'], + }), + isItem: true, + }; + } + } + + const list = elementAtPoint.closest(sortableListSelector); + if (list && root.contains(list)) { + const listData = resolveListData(list); + + if (!listData) { + continue; + } + + return { + element: list, + data: listData, + isItem: false, + }; + } + } + + return null; +} + +export function resolvePreviousSortableItemId({ + sourceItemId, + targetItem, + closestEdge, +}:{ + sourceItemId:string; + targetItem:HTMLElement; + closestEdge:Edge|null; +}):string|null { + const targetItemElement = resolveItemElement(targetItem); + const targetItemId = targetItemElement ? resolveItemId(targetItemElement) : null; + + if (closestEdge === 'bottom' && targetItemId !== sourceItemId) { + return targetItemId; + } + + const targetRow = (targetItemElement ?? targetItem).closest('li'); + let row = targetRow?.previousElementSibling ?? null; + + while (row) { + const itemId = resolvePreviousItemId(row); + if (itemId && itemId !== sourceItemId) { + return itemId; + } + + row = row.previousElementSibling; + } + + return null; +} + +export function resolveListAppendPreviousItemId({ + sourceItemId, + list, +}:{ + sourceItemId:string; + list:Element; +}):string|null { + const rows = Array.from(list.querySelectorAll(':scope > li, :scope > ul > li')).reverse(); + + for (const row of rows) { + const itemId = resolvePreviousItemId(row); + if (itemId && itemId !== sourceItemId) { + return itemId; + } + } + + return null; +} diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts new file mode 100644 index 000000000000..ad8970bb2a63 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -0,0 +1,526 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */ + +import type { draggable as draggableFn, dropTargetForElements as dropTargetForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import type { setCustomNativeDragPreview as setCustomNativeDragPreviewFn } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; +import type { preventUnhandled as preventUnhandledType } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; +import type ItemControllerType from './item.controller'; + +describe('Sortable lists item controller', () => { + let draggable:typeof draggableFn; + let dropTargetForElements:typeof dropTargetForElementsFn; + let preventUnhandled:typeof preventUnhandledType; + let setCustomNativeDragPreview:typeof setCustomNativeDragPreviewFn; + let ItemController:typeof ItemControllerType; + let sortableItemData:typeof import('./drag-and-drop').sortableItemData; + + interface TestItemController { + renderDropIndicator(edge:'top'|'bottom'|null):void; + clearDropIndicator():void; + } + + beforeAll(async () => { + vi.doMock('@atlaskit/pragmatic-drag-and-drop/combine', () => ({ + combine: vi.fn((...cleanups:(() => void)[]) => vi.fn(() => { + cleanups.forEach((cleanup) => cleanup()); + })), + })); + + vi.doMock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({ + draggable: vi.fn(() => vi.fn()), + dropTargetForElements: vi.fn(() => vi.fn()), + monitorForElements: vi.fn(() => vi.fn()), + })); + + vi.doMock('@atlaskit/pragmatic-drag-and-drop/prevent-unhandled', () => ({ + preventUnhandled: { + start: vi.fn(), + stop: vi.fn(), + }, + })); + + vi.doMock('@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview', () => ({ + setCustomNativeDragPreview: vi.fn(), + })); + + ({ draggable, dropTargetForElements } = await import('@atlaskit/pragmatic-drag-and-drop/element/adapter')); + ({ preventUnhandled } = await import('@atlaskit/pragmatic-drag-and-drop/prevent-unhandled')); + ({ setCustomNativeDragPreview } = await import('@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview')); + ({ default: ItemController } = await import('./item.controller')); + ({ sortableItemData } = await import('./drag-and-drop')); + }); + + function controllerFor(element:HTMLElement) { + const controller = Object.create(ItemController.prototype) as unknown as TestItemController; + + Object.defineProperty(controller, 'element', { value: element }); + Object.defineProperty(controller, 'idValue', { value: '1' }); + Object.defineProperty(controller, 'hasMoveUrlValue', { value: false }); + Object.defineProperty(controller, 'typeValue', { value: 'item' }); + + return controller; + } + + function connectedControllerFor(element:HTMLElement, { handle = null }:{ handle?:HTMLElement|null } = {}) { + const controller = Object.create(ItemController.prototype) as InstanceType; + + Object.defineProperty(controller, 'element', { value: element }); + Object.defineProperty(controller, 'idValue', { value: '123' }); + Object.defineProperty(controller, 'hasMoveUrlValue', { value: false }); + Object.defineProperty(controller, 'typeValue', { value: 'item' }); + Object.defineProperty(controller, 'hasHandleTarget', { value: handle !== null }); + if (handle) { + Object.defineProperty(controller, 'handleTarget', { value: handle }); + } + + controller.connect(); + + return controller; + } + + function draggableArgs(element = document.createElement('article')) { + return { + dragHandle: null, + element, + input: {} as never, + }; + } + + function dragEventPayload(element = document.createElement('article')) { + return { + location: {} as never, + source: { + data: {}, + dragHandle: null, + element, + }, + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(dropTargetForElements).mockImplementation(({ element }) => { + element.setAttribute('data-drop-target-for-element', 'true'); + + return vi.fn(() => { + element.removeAttribute('data-drop-target-for-element'); + }); + }); + }); + + it('marks the closest edge while dragging over an item', () => { + const element = document.createElement('article'); + const controller = controllerFor(element); + + controller.renderDropIndicator('top'); + + expect(element.dataset.dropPosition).toEqual('top'); + }); + + it('marks the closest edge on the containing row when present', () => { + const element = document.createElement('li'); + const controller = controllerFor(element); + + element.classList.add('Box-row'); + + controller.renderDropIndicator('top'); + + expect(element.dataset.dropPosition).toEqual('top'); + }); + + it('renders the bottom edge as the next row top edge when both describe the same insertion point', () => { + const element = document.createElement('li'); + const nextElement = document.createElement('li'); + const controller = controllerFor(element); + + element.setAttribute('data-sortable-lists--item-id-value', '1'); + nextElement.setAttribute('data-sortable-lists--item-id-value', '2'); + document.body.append(element, nextElement); + + controller.renderDropIndicator('bottom'); + + expect(element.hasAttribute('data-drop-position')).toBe(false); + expect(nextElement.dataset.dropPosition).toEqual('top'); + }); + + it('removes the drop position when leaving an item', () => { + const element = document.createElement('li'); + const nextElement = document.createElement('li'); + const controller = controllerFor(element); + + element.setAttribute('data-sortable-lists--item-id-value', '1'); + nextElement.setAttribute('data-sortable-lists--item-id-value', '2'); + document.body.append(element, nextElement); + + controller.renderDropIndicator('bottom'); + controller.clearDropIndicator(); + + expect(element.hasAttribute('data-drop-position')).toBe(false); + expect(nextElement.hasAttribute('data-drop-position')).toBe(false); + }); + + it('does not clear an indicator owned by another item controller', () => { + const element = document.createElement('li'); + const nextElement = document.createElement('li'); + const controller = controllerFor(element); + + element.setAttribute('data-sortable-lists--item-id-value', '1'); + nextElement.setAttribute('data-sortable-lists--item-id-value', '2'); + document.body.append(element, nextElement); + + controller.renderDropIndicator('bottom'); + nextElement.dataset.dropPosition = 'top'; + nextElement.dataset.dropPositionOwner = '2'; + + controller.clearDropIndicator(); + + expect(nextElement.dataset.dropPosition).toEqual('top'); + expect(nextElement.dataset.dropPositionOwner).toEqual('2'); + }); + + it('keeps the item drop target active while moving through row gaps', () => { + const element = document.createElement('article'); + + connectedControllerFor(element); + + expect(vi.mocked(dropTargetForElements).mock.lastCall?.[0].getIsSticky?.({ + element, + input: {} as never, + source: { + data: {}, + element: document.createElement('article'), + } as never, + })).toBe(true); + }); + + it('does not accept itself as an item drop target', () => { + const element = document.createElement('article'); + + connectedControllerFor(element); + + expect(vi.mocked(dropTargetForElements).mock.lastCall?.[0].canDrop?.({ + element, + input: {} as never, + source: { + data: sortableItemData({ type: 'item', itemId: '123' }), + element: document.createElement('article'), + } as never, + })).toBe(false); + }); + + it('does not expose native external drag data', () => { + const element = document.createElement('article'); + + connectedControllerFor(element); + + expect(vi.mocked(draggable).mock.lastCall?.[0].getInitialDataForExternal).toBeUndefined(); + }); + + it('prevents unhandled browser drag feedback while dragging an item', () => { + const element = document.createElement('article'); + + connectedControllerFor(element); + + vi.mocked(draggable).mock.lastCall?.[0].onDragStart?.(dragEventPayload(element)); + expect(preventUnhandled.start).toHaveBeenCalledOnce(); + + vi.mocked(draggable).mock.lastCall?.[0].onDrop?.(dragEventPayload(element)); + expect(preventUnhandled.stop).toHaveBeenCalledOnce(); + }); + + it('does not start dragging from interactive descendants', () => { + const element = document.createElement('article'); + const link = document.createElement('a'); + + link.href = '/work_packages/123'; + element.appendChild(link); + vi.spyOn(document, 'elementFromPoint').mockReturnValue(link); + connectedControllerFor(element); + + expect(vi.mocked(draggable).mock.lastCall?.[0].canDrag?.({ + element, + dragHandle: null, + input: { clientX: 10, clientY: 10 } as never, + })).toBe(false); + }); + + it('starts dragging from non-interactive descendants', () => { + const element = document.createElement('article'); + const text = document.createElement('span'); + + element.appendChild(text); + vi.spyOn(document, 'elementFromPoint').mockReturnValue(text); + connectedControllerFor(element); + + expect(vi.mocked(draggable).mock.lastCall?.[0].canDrag?.({ + element, + dragHandle: null, + input: { clientX: 10, clientY: 10 } as never, + })).toBe(true); + }); + + it('starts dragging from the focusable drag handle itself', () => { + const element = document.createElement('li'); + const handle = document.createElement('article'); + + handle.tabIndex = 0; + handle.setAttribute('data-sortable-lists--item-target', 'preview handle'); + element.appendChild(handle); + document.body.appendChild(element); + vi.spyOn(document, 'elementFromPoint').mockReturnValue(handle); + connectedControllerFor(element, { handle }); + + expect(vi.mocked(draggable).mock.lastCall?.[0].canDrag?.({ + element, + dragHandle: handle, + input: { clientX: 10, clientY: 10 } as never, + })).toBe(true); + + element.remove(); + }); + + it('does not start dragging while the sortable lists root is moving another item', () => { + const root = document.createElement('div'); + const element = document.createElement('article'); + const text = document.createElement('span'); + + root.setAttribute('data-sortable-lists-moving', 'true'); + root.setAttribute('data-controller', 'sortable-lists'); + root.appendChild(element); + element.appendChild(text); + document.body.appendChild(root); + vi.spyOn(document, 'elementFromPoint').mockReturnValue(text); + connectedControllerFor(element); + + expect(vi.mocked(draggable).mock.lastCall?.[0].canDrag?.({ + element, + dragHandle: null, + input: { clientX: 10, clientY: 10 } as never, + })).toBe(false); + + root.remove(); + }); + + describe('Stimulus application wiring', () => { + let ctx:StimulusTestContext; + let fixture:HTMLElement; + + beforeEach(async () => { + ctx = await setupStimulusTest({ + controllers: { + 'sortable-lists--item': ItemController, + }, + }); + fixture = ctx.container; + }); + + afterEach(() => { + ctx.dispose(); + }); + + function renderBacklogsRow(itemId = '123') { + fixture.innerHTML = ` +
    • +
      + +
      +
    • + `; + + return { + row: fixture.querySelector('.Box-row')!, + article: fixture.querySelector('[data-controller="backlogs--story"]')!, + }; + } + + it('registers the row as both draggable and drop target', async () => { + const { row } = renderBacklogsRow(); + + await ctx.nextFrame(); + + expect(vi.mocked(draggable)).toHaveBeenCalledWith(expect.objectContaining({ + element: row, + })); + expect(vi.mocked(dropTargetForElements)).toHaveBeenCalledWith(expect.objectContaining({ + element: row, + })); + }); + + it('uses the handle target as the pointer drag handle without adding drag ARIA', async () => { + const { article } = renderBacklogsRow(); + + await ctx.nextFrame(); + + expect(vi.mocked(draggable)).toHaveBeenCalledWith(expect.objectContaining({ + dragHandle: article, + })); + expect(article.hasAttribute('aria-roledescription')).toBe(false); + expect(article.hasAttribute('aria-disabled')).toBe(false); + expect(article.hasAttribute('aria-pressed')).toBe(false); + expect(article.hasAttribute('role')).toBe(false); + expect(article.getAttribute('tabindex')).toEqual('0'); + expect(fixture.querySelector('[id^="sortable-lists-drag-handle-instructions"]')).toBeNull(); + }); + + it('does not intercept keyboard events on the card handle', async () => { + const { row, article } = renderBacklogsRow(); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: ' ', + }); + + await ctx.nextFrame(); + article.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + expect(row.hasAttribute('data-dragging')).toBe(false); + }); + + it('includes the optional move URL in the sortable item data', async () => { + const { article } = renderBacklogsRow(); + + await ctx.nextFrame(); + + expect(vi.mocked(draggable).mock.lastCall?.[0].getInitialData?.(draggableArgs(article))).toEqual(expect.objectContaining({ + itemId: '123', + moveUrl: '/move', + type: 'work_package', + })); + }); + + it('renders a sanitized copy of the preview target for the native drag preview', async () => { + const { article } = renderBacklogsRow(); + const nativeSetDragImage = vi.fn(); + const previewContainer = document.createElement('div'); + + vi.spyOn(article, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 320, + bottom: 64, + width: 320, + height: 64, + toJSON: vi.fn(), + }); + + await ctx.nextFrame(); + + vi.mocked(draggable).mock.lastCall?.[0].onGenerateDragPreview?.({ + ...dragEventPayload(article), + nativeSetDragImage, + }); + + expect(setCustomNativeDragPreview).toHaveBeenCalledOnce(); + + const previewOptions = vi.mocked(setCustomNativeDragPreview).mock.lastCall?.[0] as { + render:({ container }:{ container:HTMLElement }) => void; + nativeSetDragImage:typeof nativeSetDragImage; + }; + + expect(previewOptions.nativeSetDragImage).toBe(nativeSetDragImage); + + previewOptions.render({ container: previewContainer }); + + const preview = previewContainer.firstElementChild as HTMLElement; + + expect(preview).not.toBe(article); + expect(preview.tagName).toEqual('ARTICLE'); + expect(preview.style.width).toEqual('320px'); + expect(preview.hasAttribute('data-preview')).toBe(true); + expect(preview.hasAttribute('data-controller')).toBe(false); + expect(preview.hasAttribute('data-sortable-lists--item-target')).toBe(false); + expect(preview.hasAttribute('data-action')).toBe(false); + expect(preview.hasAttribute('data-dragging')).toBe(false); + expect(preview.hasAttribute('data-drop-position')).toBe(false); + expect(preview.hasAttribute('data-drop-position-owner')).toBe(false); + expect(preview.hasAttribute('aria-roledescription')).toBe(false); + expect(preview.hasAttribute('aria-describedby')).toBe(false); + expect(preview.hasAttribute('aria-disabled')).toBe(false); + expect(preview.querySelector('[data-controller]')).toBeNull(); + expect(preview.querySelector('[data-action]')).toBeNull(); + expect(preview.querySelector('[data-backlogs--story-target]')).toBeNull(); + }); + + it('re-registers the row when refreshed after a Turbo morph drops the Pragmatic DnD attributes', async () => { + const { row } = renderBacklogsRow(); + + await ctx.nextFrame(); + expect(row.dataset.dropTargetForElement).toEqual('true'); + + row.removeAttribute('data-drop-target-for-element'); + + const controller = ctx.getController>('sortable-lists--item', row); + controller.refresh(); + + expect(row.dataset.dropTargetForElement).toEqual('true'); + expect(vi.mocked(draggable)).toHaveBeenCalledTimes(2); + expect(vi.mocked(dropTargetForElements)).toHaveBeenCalledTimes(2); + expect(vi.mocked(dropTargetForElements).mock.lastCall?.[0]).toEqual(expect.objectContaining({ + element: row, + })); + }); + + it('skips refreshing while a drag is in flight', async () => { + const { row } = renderBacklogsRow(); + + await ctx.nextFrame(); + row.setAttribute('data-dragging', 'source'); + + const controller = ctx.getController>('sortable-lists--item', row); + controller.refresh(); + + expect(vi.mocked(draggable)).toHaveBeenCalledTimes(1); + expect(vi.mocked(dropTargetForElements)).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts new file mode 100644 index 000000000000..5bde75e45a62 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -0,0 +1,280 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { + attachClosestEdge, + type Edge, + extractClosestEdge, +} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; +import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; +import { Controller } from '@hotwired/stimulus'; +import { closestInteractiveElement } from 'core-stimulus/helpers/interactive-element-helper'; +import { + isSortableItemData, + sortableItemSelector, + sortableItemData, + sortableListsMovingAttribute, + sortableListsRootSelector, + type SortableItemData, +} from './drag-and-drop'; + +type CleanupFn = () => void; + +// Attributes stripped from the cloned drag preview so it carries no behaviour or +// stale interaction state. The dynamic `data-*--*-target` attributes are removed +// separately in sanitizePreview. +const PREVIEW_STRIPPED_ATTRIBUTES = [ + 'data-controller', + 'data-action', + 'data-dragging', + 'data-drop-position', + 'data-drop-position-owner', + 'aria-describedby', + 'aria-disabled', + 'aria-roledescription', +] as const; + +export default class ItemController extends Controller { + static targets = ['handle', 'preview']; + + static values = { + id: String, + moveUrl: String, + type: { type: String, default: 'item' }, + }; + + declare idValue:string; + declare moveUrlValue:string; + declare typeValue:string; + + declare readonly handleTarget:HTMLElement; + declare readonly hasHandleTarget:boolean; + declare readonly previewTarget:HTMLElement; + declare readonly hasPreviewTarget:boolean; + + private cleanupFn?:CleanupFn; + private dropIndicatorElement?:HTMLElement; + + connect() { + this.cleanupFn = combine( + this.registerDraggable(), + this.registerDropTarget(), + ); + } + + disconnect() { + this.cleanupFn?.(); + this.cleanupFn = undefined; + } + + // Re-run setup after a Turbo morph re-applies the static markup and drops the + // Pragmatic DnD attributes/listeners this controller added. The root + // sortable-lists controller calls this from a single morph listener. Skip + // while an interaction is in flight so an active drag is not torn down + // underneath the user. + refresh():void { + if (this.element.hasAttribute('data-dragging')) { + return; + } + + this.disconnect(); + this.connect(); + } + + private registerDraggable():CleanupFn { + return draggable({ + element: this.element, + ...(this.hasHandleTarget ? { dragHandle: this.handleTarget } : {}), + canDrag: ({ input }) => this.canDragFromPoint(input.clientX, input.clientY), + getInitialData: () => this.getItemData(), + onDragStart: () => { + preventUnhandled.start(); + this.element.setAttribute('data-dragging', 'source'); + }, + onDrop: () => { + preventUnhandled.stop(); + this.clearDropIndicator(); + this.element.removeAttribute('data-dragging'); + }, + onGenerateDragPreview: ({ nativeSetDragImage }) => { + if (!this.hasPreviewTarget) { + return; + } + + setCustomNativeDragPreview({ + nativeSetDragImage, + render: ({ container }) => this.renderPreview(container), + }); + }, + }); + } + + private registerDropTarget():CleanupFn { + return dropTargetForElements({ + element: this.element, + canDrop: ({ source }) => { + return isSortableItemData(source.data) && source.data.itemId !== this.idValue; + }, + getData: ({ input }) => { + return attachClosestEdge(this.getItemData(), { + element: this.element, + input, + allowedEdges: ['top', 'bottom'], + }); + }, + getIsSticky: () => true, + onDragEnter: ({ self }) => { + const closestEdge = extractClosestEdge(self.data); + this.renderDropIndicator(closestEdge); + }, + onDrag: ({ self }) => { + const closestEdge = extractClosestEdge(self.data); + this.renderDropIndicator(closestEdge); + }, + onDragLeave: () => { + this.clearDropIndicator(); + }, + onDrop: () => { + this.clearDropIndicator(); + }, + }); + } + + private canDragFromPoint(clientX:number, clientY:number):boolean { + if (this.element.closest(sortableListsRootSelector)?.hasAttribute(sortableListsMovingAttribute)) { + return false; + } + + const target = this.element.ownerDocument.elementFromPoint(clientX, clientY); + + if (!(target instanceof Element) || !this.element.contains(target)) { + return true; + } + + const dragHandle = this.hasHandleTarget ? this.handleTarget : this.element; + + return closestInteractiveElement(target, dragHandle) == null; + } + + private getItemData():SortableItemData { + return sortableItemData({ + itemId: this.idValue, + moveUrl: this.moveUrlValue || undefined, + type: this.typeValue, + }); + } + + private renderPreview(container:HTMLElement) { + const previewWidth = this.previewTarget.getBoundingClientRect().width; + const preview = this.previewTarget.cloneNode(true) as HTMLElement; + + this.sanitizePreview(preview); + preview.setAttribute('data-preview', ''); + + if (previewWidth > 0) { + preview.style.width = `${previewWidth}px`; + } + + container.append(preview); + } + + private sanitizePreview(element:HTMLElement) { + const nodes = [element, ...Array.from(element.querySelectorAll('*'))]; + + for (const node of nodes) { + PREVIEW_STRIPPED_ATTRIBUTES.forEach((attribute) => node.removeAttribute(attribute)); + + for (const attribute of Array.from(node.attributes)) { + if (/^data-.+--.+-target$/.test(attribute.name)) { + node.removeAttribute(attribute.name); + } + } + } + } + + private renderDropIndicator(edge:Edge|null) { + const currentEdge = this.dropIndicatorElement?.dataset.dropPosition; + const currentOwner = this.dropIndicatorElement?.dataset.dropPositionOwner; + const nextIndicator = edge ? this.resolveDropIndicator(edge) : null; + + if ( + currentOwner === this.idValue && + nextIndicator && + this.dropIndicatorElement === nextIndicator.element && + currentEdge === nextIndicator.edge + ) { + return; + } + + this.clearDropIndicator(); + + if (nextIndicator) { + this.renderDropIndicatorOn(nextIndicator.element, nextIndicator.edge); + } + } + + private resolveDropIndicator(edge:Edge):{ element:HTMLElement; edge:Edge } { + if (edge !== 'bottom') { + return { element: this.element, edge }; + } + + const nextItem = this.element.nextElementSibling; + + if ( + nextItem instanceof HTMLElement && + nextItem.matches(sortableItemSelector) && + !nextItem.hasAttribute('data-dragging') + ) { + return { element: nextItem, edge: 'top' }; + } + + return { element: this.element, edge }; + } + + private renderDropIndicatorOn(element:HTMLElement, edge:Edge):void { + this.dropIndicatorElement = element; + element.dataset.dropPosition = edge; + element.dataset.dropPositionOwner = this.idValue; + } + + private clearDropIndicator() { + if (!this.dropIndicatorElement) { + return; + } + + if (this.dropIndicatorElement.dataset.dropPositionOwner === this.idValue) { + delete this.dropIndicatorElement.dataset.dropPosition; + delete this.dropIndicatorElement.dataset.dropPositionOwner; + } + + this.dropIndicatorElement = undefined; + } +} diff --git a/frontend/src/stimulus/helpers/request-helpers.spec.ts b/frontend/src/stimulus/helpers/request-helpers.spec.ts new file mode 100644 index 000000000000..8a39adb5d30b --- /dev/null +++ b/frontend/src/stimulus/helpers/request-helpers.spec.ts @@ -0,0 +1,77 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import type { FetchResponse } from '@rails/request.js'; + +import { withLoadingIndicator } from './request-helpers'; + +describe('withLoadingIndicator', () => { + let indicator:HTMLElement|null; + + function addIndicator() { + const element = document.createElement('div'); + element.id = 'global-loading-indicator'; + element.hidden = true; + document.body.appendChild(element); + indicator = element; + return element; + } + + afterEach(() => { + indicator?.remove(); + indicator = null; + }); + + it('shows the indicator while the request is pending and hides it on success', async () => { + const element = addIndicator(); + let resolveRequest!:(response:FetchResponse) => void; + const request = new Promise((resolve) => { resolveRequest = resolve; }); + + const wrapped = withLoadingIndicator(request); + expect(element).toBeVisible(); + + resolveRequest({} as FetchResponse); + await wrapped; + + expect(element).not.toBeVisible(); + }); + + it('hides the indicator when the request rejects and propagates the rejection', async () => { + const element = addIndicator(); + const error = new Error('boom'); + + const wrapped = withLoadingIndicator(Promise.reject(error)); + + await expect(wrapped).rejects.toBe(error); + expect(element).not.toBeVisible(); + }); + + it('throws when the loading indicator is absent', () => { + expect(() => withLoadingIndicator(Promise.resolve({} as FetchResponse))).toThrow(); + }); +}); diff --git a/frontend/src/stimulus/helpers/request-helpers.ts b/frontend/src/stimulus/helpers/request-helpers.ts index 8c72e6002f67..0e4f3f76dac6 100644 --- a/frontend/src/stimulus/helpers/request-helpers.ts +++ b/frontend/src/stimulus/helpers/request-helpers.ts @@ -37,14 +37,13 @@ export function post(url:string|URL, options?:Options) { return withLoadingIndicator(request.perform()); } -function withLoadingIndicator(request:Promise) { +export function withLoadingIndicator(request:Promise) { const loadingIndicator = document.querySelector('#global-loading-indicator'); invariant(loadingIndicator, 'Expected an Element with id global-loading-indicator to be present'); showElement(loadingIndicator); - return request.then((response) => { + return request.finally(() => { hideElement(loadingIndicator); - return response; }); } diff --git a/modules/backlogs/app/components/backlogs/bucket_component.html.erb b/modules/backlogs/app/components/backlogs/bucket_component.html.erb index 987b849544a5..36ae1143917c 100644 --- a/modules/backlogs/app/components/backlogs/bucket_component.html.erb +++ b/modules/backlogs/app/components/backlogs/bucket_component.html.erb @@ -35,8 +35,8 @@ See COPYRIGHT and LICENSE files for more details. project:, container: backlog_bucket, drag_and_drop: { - target_id: "backlog_bucket:#{backlog_bucket.id}", - allowed_drag_type: "story" + list_type: "backlog_bucket", + list_id: backlog_bucket.id }, params: all_backlogs_params, current_user:, diff --git a/modules/backlogs/app/components/backlogs/inbox_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_component.html.erb index 1ddebd3d00ca..9de40a44a64c 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb @@ -39,10 +39,8 @@ See COPYRIGHT and LICENSE files for more details. header_padding: :default, test_selector: "backlog-inbox", data: { - generic_drag_and_drop_target: "container", - target_container_accessor: ":scope > ul", - target_id: "inbox", - target_allowed_drag_type: "story" + sortable_lists_target: "list", + sortable_lists_list_type: "inbox" } ) ) do |list| @@ -67,7 +65,7 @@ See COPYRIGHT and LICENSE files for more details. list.with_item( scheme: :neutral, classes: "op-work-package-card-list--show-more-row", - data: { draggable_id: last_omitted_id } + data: { sortable_lists_prev_item_id: last_omitted_id } ) do render( Primer::Beta::Button.new( diff --git a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb index 7d6c206f44b2..7abf18b853af 100644 --- a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb @@ -32,21 +32,20 @@ See COPYRIGHT and LICENSE files for more details. d.with_header(variant: :large) d.with_body do + available_buckets = buckets + primer_form_with( url: move_action, method: :put, id: FORM_ID, data: { turbo_stream: true } - ) do - render( - Primer::Alpha::Select.new( - name: "target_id", - label: BacklogBucket.human_attribute_name(:name), - visually_hide_label: true - ) - ) do |select| - buckets.each do |bucket| - select.option(label: bucket.name, value: "backlog_bucket:#{bucket.id}") + ) do |f| + render_inline_form(f) do |form| + form.hidden(name: "list_type", value: "backlog_bucket") + form.select_list(name: "list_id", label: BacklogBucket.human_attribute_name(:name), visually_hide_label: true) do |select| + available_buckets.each do |bucket| + select.option(label: bucket.name, value: bucket.id) + end end end end diff --git a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb index a05bc079db0b..93057cdf8c73 100644 --- a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb @@ -32,21 +32,20 @@ See COPYRIGHT and LICENSE files for more details. d.with_header(variant: :large) d.with_body do + available_sprints = sprints + primer_form_with( url: move_action, method: :put, id: FORM_ID, data: { turbo_stream: true } - ) do - render( - Primer::Alpha::Select.new( - name: "target_id", - label: Sprint.human_model_name, - visually_hide_label: true - ) - ) do |select| - sprints.each do |sprint| - select.option(label: sprint.name, value: "sprint:#{sprint.id}") + ) do |f| + render_inline_form(f) do |form| + form.hidden(name: "list_type", value: "sprint") + form.select_list(name: "list_id", label: Sprint.human_model_name, visually_hide_label: true) do |select| + available_sprints.each do |sprint| + select.option(label: sprint.name, value: sprint.id) + end end end end diff --git a/modules/backlogs/app/components/backlogs/sprint_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_component.html.erb index 410649bccca8..df864d9b0b7a 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_component.html.erb @@ -35,8 +35,8 @@ See COPYRIGHT and LICENSE files for more details. project:, container: sprint, drag_and_drop: { - target_id: "sprint:#{sprint.id}", - allowed_drag_type: "story" + list_type: "sprint", + list_id: sprint.id }, params: all_backlogs_params, current_user:, diff --git a/modules/backlogs/app/components/backlogs/work_package_card_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_component.rb index 16268a7a0563..748b4fcea696 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_component.rb @@ -34,11 +34,12 @@ class WorkPackageCardComponent < ApplicationComponent delegate :with_menu, :with_metric, to: :card - def initialize(work_package:, menu_src: nil) + def initialize(work_package:, menu_src: nil, **system_arguments) super() @work_package = work_package @menu_src = menu_src + @system_arguments = system_arguments end def call @@ -60,7 +61,8 @@ def card show_assignee: true, show_priority: true, show_parent: true, - status_scheme: :secondary + status_scheme: :secondary, + **@system_arguments ) end diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb index 761716d7bc3e..e84ad86aa286 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb @@ -115,12 +115,12 @@ def merge_drag_and_drop_data! end def drag_and_drop_data - { - generic_drag_and_drop_target: "container", - target_container_accessor: ":scope > ul", - target_id: drag_and_drop.fetch(:target_id), - target_allowed_drag_type: drag_and_drop.fetch(:allowed_drag_type) + data = { + sortable_lists_target: "list", + sortable_lists_list_type: drag_and_drop.fetch(:list_type) } + data[:sortable_lists_list_id] = drag_and_drop[:list_id] if drag_and_drop[:list_id].present? + data end def default_count_label(count) diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb index 46530dd79da0..b67d264b0640 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb @@ -35,7 +35,11 @@ class WorkPackageCardListItemComponent < OpenProject::Common::BorderBoxListCompo private def build_card - WorkPackageCardComponent.new(work_package:, menu_src:) + WorkPackageCardComponent.new( + work_package:, + menu_src:, + **card_arguments + ) end def draggable? @@ -50,7 +54,7 @@ def full_url url_helpers.work_package_path(work_package) end - def drop_url + def move_url url_helpers.move_project_backlogs_work_package_path(project, work_package, params) end @@ -58,10 +62,16 @@ def menu_src url_helpers.menu_project_backlogs_work_package_path(project, work_package, params) end - # `story` data attrs match the live Stimulus controller and Dragula - # drag-type; renaming requires coordinated JS changes (separate PR). - def row_data - super.merge( + def card_arguments + { + classes: "Box-card", + tabindex: 0, + data: card_data + } + end + + def card_data + data = { story: true, controller: "backlogs--story", backlogs__story_id_value: work_package.id, @@ -69,15 +79,29 @@ def row_data backlogs__story_split_url_value: split_url, backlogs__story_full_url_value: full_url, backlogs__story_selected_class: "Box-row--blue" - ) + } + + return data unless draggable? + + data.merge(sortable_lists__item_target: "preview handle") end def draggable_data { - draggable_id: work_package.id, - draggable_type: "story", - drop_url: + controller: "sortable-lists--item", + sortable_lists__item_id_value: work_package.id, + sortable_lists__item_type_value: "work_package", + sortable_lists__item_move_url_value: move_url } end + + public + + def row_args + arguments = super + arguments.delete(:tabindex) + arguments[:draggable] = true if draggable? + arguments + end end end diff --git a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb index 4c4a4225c734..7e7acd0bab79 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb @@ -88,7 +88,7 @@ See COPYRIGHT and LICENSE files for more details. label: t(".action_menu.move_to_inbox"), tag: :button, href: move_project_backlogs_work_package_path(project, work_package, all_backlogs_params), - form_arguments: { method: :put, inputs: [{ name: "target_id", value: "inbox" }] } + form_arguments: { method: :put, inputs: [{ name: "list_type", value: "inbox" }] } ) do |item| item.with_leading_visual_icon(icon: :inbox) end diff --git a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb index 0289a1371494..4fa61a08dc00 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb @@ -93,7 +93,7 @@ def build_move_item(menu, label:, icon:, direction: nil, prev_id: nil) inputs = if direction [{ name: "direction", value: direction }] else - [{ name: "target_id", value: move_target_id }, { name: "prev_id", value: prev_id }] + current_list_inputs + [{ name: "prev_id", value: prev_id }] end menu.with_item( @@ -115,14 +115,16 @@ def last_item? work_package.next_id.nil? end - def move_target_id - @move_target_id ||= if work_package.backlog_bucket_id? - "backlog_bucket:#{work_package.backlog_bucket_id}" - elsif work_package.sprint_id? - "sprint:#{work_package.sprint_id}" - else - "inbox" - end + def current_list_inputs + list_type, list_id = if work_package.backlog_bucket_id? + ["backlog_bucket", work_package.backlog_bucket_id] + elsif work_package.sprint_id? + ["sprint", work_package.sprint_id] + else + ["inbox", nil] + end + + [{ name: "list_type", value: list_type }, { name: "list_id", value: list_id }] end end end diff --git a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb index 83730c937b89..e63a8e0e46cc 100644 --- a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb @@ -68,7 +68,7 @@ def move # rubocop:disable Metrics/AbcSize source = @work_package.sprint call = ::Backlogs::WorkPackages::UpdateService.new(user: current_user, story: @work_package) - .call(**move_params.to_h.symbolize_keys) + .call(**move_service_params) if call.success? move_work_package_to_target_component_via_turbo_stream(source:, target: call.result.sprint) @@ -126,7 +126,14 @@ def load_work_package end def move_params - params.permit(:position, :prev_id, :target_id, :direction) + params.permit(:prev_id, :direction, :list_type, :list_id) + end + + # A blank prev_id (drag or menu move to the top of a list) is kept so the + # service inserts at the top; nil values (absent prev_id/direction) are + # dropped. The service resolves list_type/list_id into the destination list. + def move_service_params + move_params.to_h.symbolize_keys.compact end def displayed_work_packages diff --git a/modules/backlogs/app/services/backlogs/work_packages/update_service.rb b/modules/backlogs/app/services/backlogs/work_packages/update_service.rb index cb5664a763c3..5885ccddf4fb 100644 --- a/modules/backlogs/app/services/backlogs/work_packages/update_service.rb +++ b/modules/backlogs/app/services/backlogs/work_packages/update_service.rb @@ -36,8 +36,8 @@ def initialize(user:, story:) self.story = story end - def call(direction: nil, target_id: nil, position: nil, prev_id: nil) - resolve_required_attributes(direction:, target_id:) + def call(direction: nil, list_type: nil, list_id: nil, position: nil, prev_id: nil) + resolve_required_attributes(direction:, list_type:, list_id:) .bind { |attrs| ::WorkPackages::UpdateService.new(user:, model: story).call(**attrs) } .on_success do |call| if prev_id @@ -50,11 +50,13 @@ def call(direction: nil, target_id: nil, position: nil, prev_id: nil) private - def resolve_required_attributes(direction:, target_id:) - if target_id && direction + def resolve_required_attributes(direction:, list_type:, list_id:) + list_id = list_id.presence&.to_s + + if list_type.present? && direction ServiceResult.failure(message: I18n.t("backlogs.stories.update_service.ambiguous_target")) - elsif target_id - attributes_result_from_target(target_id) + elsif list_type.present? + attributes_result_from_list(list_type, list_id) elsif direction attributes_result_from_direction(direction) else @@ -62,13 +64,13 @@ def resolve_required_attributes(direction:, target_id:) end end - def attributes_result_from_target(target_id) - case target_id.to_s.split(":", 2) + def attributes_result_from_list(list_type, list_id) + case [list_type, list_id] in ["sprint", /\A\d+\z/ => sprint_id] ServiceResult.success(result: { backlog_bucket_id: nil, sprint_id: }) in ["backlog_bucket", /\A\d+\z/ => backlog_bucket_id] ServiceResult.success(result: { backlog_bucket_id:, sprint_id: nil }) - in ["inbox"] + in ["inbox", nil] ServiceResult.success(result: { backlog_bucket_id: nil, sprint_id: nil }) else ServiceResult.failure(message: I18n.t("backlogs.stories.update_service.invalid_target_type")) diff --git a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb index a7e9444f36be..0e384062a930 100644 --- a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb @@ -27,12 +27,14 @@ See COPYRIGHT and LICENSE files for more details. ++# %> -<%= turbo_frame_tag "backlogs_container", refresh: :morph, class: "op-backlogs-page" do %> -
      -
      +<%= turbo_frame_tag "backlogs_container", + refresh: :morph, + class: "op-backlogs-page", + data: { controller: "backlogs--list-refresh" } do %> +
      +
      <%= render Backlogs::BacklogComponent.new( buckets: @backlog_buckets, @@ -41,8 +43,7 @@ See COPYRIGHT and LICENSE files for more details. ) %>
      -
      +
      <%= render Backlogs::SprintsComponent.new( sprints: @sprints, diff --git a/modules/backlogs/app/views/backlogs/backlog/show.html.erb b/modules/backlogs/app/views/backlogs/backlog/show.html.erb index 1cefd15bc236..7be34c05bf77 100644 --- a/modules/backlogs/app/views/backlogs/backlog/show.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/show.html.erb @@ -29,9 +29,6 @@ See COPYRIGHT and LICENSE files for more details. <% html_title t(:label_backlogs) %> -<% content_controller "backlogs", - "backlogs-list-url-value": project_backlogs_backlog_path(@project) %> - <% content_for :content_header do %> <%= render Primer::OpenProject::PageHeader.new(mb: 0) do |header| diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index d898370d1869..78cbad091e0c 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -202,10 +202,10 @@ en: stories: update_service: - missing_target: "target_id or direction must be present." - ambiguous_target: "target_id and direction cannot both be present." - invalid_target_type: "target_type must be one of: backlog_bucket:, sprint:, inbox." + ambiguous_target: "list_type and direction cannot both be present." invalid_direction: "direction must be one of: higher, highest, lower, lowest." + invalid_target_type: "list_type must be one of: backlog_bucket with a list_id, sprint with a list_id, or inbox without a list_id." + missing_target: "list_type or direction must be present." work_package_card_menu_component: action_menu: diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index d752faebdb6b..3b331d52b64a 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -120,9 +120,9 @@ def render_component it "wires the bucket drop-target data on the box" do expect(rendered_component).to have_css(".Box") do |box| - expect(box["data-generic-drag-and-drop-target"]).to eq("container") - expect(box["data-target-id"]).to eq("backlog_bucket:#{backlog_bucket.id}") - expect(box["data-target-allowed-drag-type"]).to eq("story") + expect(box["data-sortable-lists-target"]).to eq("list") + expect(box["data-sortable-lists-list-type"]).to eq("backlog_bucket") + expect(box["data-sortable-lists-list-id"]).to eq(backlog_bucket.id.to_s) end end @@ -133,15 +133,22 @@ def render_component ) end - it "wires draggable row data through the shared card" do + it "wires draggable data through the shared row" do expect(rendered_component).to have_css(".Box-row#work_package_#{work_package.id}") do |row| - expect(row["data-controller"]).to eq("backlogs--story") - expect(row["data-draggable-id"]).to eq(work_package.id.to_s) - expect(row["data-draggable-type"]).to eq("story") - expect(row["data-drop-url"]).to end_with(move_project_backlogs_work_package_path(project, work_package)) - expect(row["data-backlogs--story-split-url-value"]) + expect(row["data-controller"]).to eq("sortable-lists--item") + expect(row["data-sortable-lists--item-id-value"]).to eq(work_package.id.to_s) + expect(row["data-sortable-lists--item-type-value"]).to eq("work_package") + expect(row["data-sortable-lists--item-move-url-value"]) + .to end_with(move_project_backlogs_work_package_path(project, work_package)) + expect(row["draggable"]).to eq("true") + end + + expect(rendered_component).to have_css(".op-work-package-card") do |card| + expect(card["data-controller"]).to eq("backlogs--story") + expect(card["data-sortable-lists--item-target"]).to eq("preview handle") + expect(card["data-backlogs--story-split-url-value"]) .to end_with(project_backlogs_backlog_details_path(project, work_package)) - expect(row["data-backlogs--story-full-url-value"]) + expect(card["data-backlogs--story-full-url-value"]) .to end_with(work_package_path(work_package)) end end @@ -171,14 +178,14 @@ def render_component end it "includes all=1 in the split-view URL" do - expect(rendered_component).to have_css(".Box-row#work_package_#{work_package.id}") do |row| - expect(row["data-backlogs--story-split-url-value"]).to include("all=1") + expect(rendered_component).to have_css(".op-work-package-card") do |card| + expect(card["data-backlogs--story-split-url-value"]).to include("all=1") end end it "includes all=1 in the drop URL" do expect(rendered_component).to have_css(".Box-row#work_package_#{work_package.id}") do |row| - expect(row["data-drop-url"]).to include("all=1") + expect(row["data-sortable-lists--item-move-url-value"]).to include("all=1") end end @@ -232,8 +239,15 @@ def render_component it "does not mark work package rows as draggable" do expect(rendered_component).to have_css(".Box-row#work_package_#{work_package.id}") expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package.id}.Box-row--draggable") - expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package.id}[data-draggable-id]") - expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package.id}[data-drop-url]") + expect(rendered_component) + .to have_no_css(".Box-row#work_package_#{work_package.id}[data-sortable-lists--item-id-value]") + expect(rendered_component) + .to have_no_css(".Box-row#work_package_#{work_package.id}[data-sortable-lists--item-move-url-value]") + expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package.id}[draggable='true']") + expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-id-value]") + expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-target]") + expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-move-url-value]") + expect(rendered_component).to have_no_css(".op-work-package-card[draggable='true']") end end end diff --git a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb index 13436faeca9f..773ce9004903 100644 --- a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb @@ -64,9 +64,9 @@ def render_component it "wires drop-target data attributes for the inbox" do expect(page).to have_css(".Box#inbox_project_#{project.id}") do |box| - expect(box["data-generic-drag-and-drop-target"]).to eq("container") - expect(box["data-target-id"]).to eq("inbox") - expect(box["data-target-allowed-drag-type"]).to eq("story") + expect(box["data-sortable-lists-target"]).to eq("list") + expect(box["data-sortable-lists-list-type"]).to eq("inbox") + expect(box["data-sortable-lists-list-id"]).to be_nil end end @@ -175,10 +175,10 @@ def render_component expect(show_link["data-turbo-frame"]).to eq("backlogs_container") end - it "renders the show-more row with the last omitted work package id" do + it "renders the show-more row with the previous item id" do last_omitted = work_packages.sort_by(&:position)[-(tail_size + 1)] - expect(page).to have_css("[data-draggable-id='#{last_omitted.id}']") + expect(page).to have_css("[data-sortable-lists-prev-item-id='#{last_omitted.id}']") end end diff --git a/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb b/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb index 8f038feb77eb..020da3c14609 100644 --- a/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb @@ -78,11 +78,12 @@ def render_component let!(:bucket_a) { create(:backlog_bucket, project:, name: "Alpha") } let!(:bucket_b) { create(:backlog_bucket, project:, name: "Beta") } - it "lists them as select options with backlog_bucket: prefix values" do + it "submits backlog_bucket list data" do render_component - expect(page).to have_element(:option, value: "backlog_bucket:#{bucket_a.id}", text: "Alpha") - expect(page).to have_element(:option, value: "backlog_bucket:#{bucket_b.id}", text: "Beta") + expect(page).to have_css("input[name='list_type'][value='backlog_bucket']", visible: :all) + expect(page).to have_element(:option, value: bucket_a.id, text: "Alpha") + expect(page).to have_element(:option, value: bucket_b.id, text: "Beta") end end @@ -105,7 +106,7 @@ def render_component render_component expect(page).to have_no_css(:option, text: "Current") - expect(page).to have_element(:option, value: "backlog_bucket:#{target_bucket.id}", text: "Target") + expect(page).to have_element(:option, value: target_bucket.id, text: "Target") end end end diff --git a/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb b/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb index 86c9947db799..b12c8fb6ebe2 100644 --- a/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb @@ -78,11 +78,12 @@ def render_component let!(:planning_sprint) { create(:sprint, project:, name: "Planning Sprint", status: "in_planning") } let!(:active_sprint) { create(:sprint, project:, name: "Active Sprint", status: "active") } - it "lists them as select options with sprint: prefix values" do + it "submits sprint list data" do render_component - expect(page).to have_css("option[value='sprint:#{planning_sprint.id}']", text: "Planning Sprint") - expect(page).to have_css("option[value='sprint:#{active_sprint.id}']", text: "Active Sprint") + expect(page).to have_css("input[name='list_type'][value='sprint']", visible: :all) + expect(page).to have_css("option[value='#{planning_sprint.id}']", text: "Planning Sprint") + expect(page).to have_css("option[value='#{active_sprint.id}']", text: "Active Sprint") end end @@ -115,7 +116,7 @@ def render_component render_component expect(page).to have_no_css("option", text: "Current Sprint") - expect(page).to have_css("option[value='sprint:#{target_sprint.id}']", text: "Target Sprint") + expect(page).to have_css("option[value='#{target_sprint.id}']", text: "Target Sprint") end end diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index 7c2876a089a4..289251e0532a 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -103,10 +103,9 @@ def menu_items it "wires drop-target data attributes for the sprint" do expect(rendered_component).to have_css(".Box") do |box| - expect(box["data-generic-drag-and-drop-target"]).to eq("container") - expect(box["data-target-container-accessor"]).to eq(":scope > ul") - expect(box["data-target-id"]).to eq("sprint:#{sprint.id}") - expect(box["data-target-allowed-drag-type"]).to eq("story") + expect(box["data-sortable-lists-target"]).to eq("list") + expect(box["data-sortable-lists-list-type"]).to eq("sprint") + expect(box["data-sortable-lists-list-id"]).to eq(sprint.id.to_s) end end @@ -116,11 +115,18 @@ def menu_items it "wires draggable data on work package rows" do expect(rendered_component).to have_css(".Box-row#work_package_#{work_package1.id}") do |row| - expect(row["data-draggable-id"]).to eq(work_package1.id.to_s) - expect(row["data-draggable-type"]).to eq("story") - expect(row["data-backlogs--story-display-id-value"]).to eq(work_package1.display_id.to_s) - expect(row["data-drop-url"]) + expect(row["data-controller"]).to eq("sortable-lists--item") + expect(row["data-sortable-lists--item-id-value"]).to eq(work_package1.id.to_s) + expect(row["data-sortable-lists--item-type-value"]).to eq("work_package") + expect(row["data-sortable-lists--item-move-url-value"]) .to end_with(move_project_backlogs_work_package_path(project, work_package1)) + expect(row["draggable"]).to eq("true") + end + + expect(rendered_component).to have_css(".Box-row#work_package_#{work_package1.id} .op-work-package-card") do |card| + expect(card["data-controller"]).to eq("backlogs--story") + expect(card["data-sortable-lists--item-target"]).to eq("preview handle") + expect(card["data-backlogs--story-display-id-value"]).to eq(work_package1.display_id.to_s) end end @@ -131,7 +137,7 @@ def menu_items it "propagates ?all=1 to the work package drop URL" do expect(rendered_component).to have_css(".Box-row#work_package_#{work_package1.id}") do |row| - expect(row["data-drop-url"]) + expect(row["data-sortable-lists--item-move-url-value"]) .to eq(move_project_backlogs_work_package_path(project, work_package1, all: "1")) end end @@ -153,8 +159,15 @@ def menu_items it "does not mark work package rows as draggable" do expect(rendered_component).to have_css(".Box-row#work_package_#{work_package1.id}") expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package1.id}.Box-row--draggable") - expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package1.id}[data-draggable-id]") - expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package1.id}[data-drop-url]") + expect(rendered_component) + .to have_no_css(".Box-row#work_package_#{work_package1.id}[data-sortable-lists--item-id-value]") + expect(rendered_component) + .to have_no_css(".Box-row#work_package_#{work_package1.id}[data-sortable-lists--item-move-url-value]") + expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package1.id}[draggable='true']") + expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-id-value]") + expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-target]") + expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-move-url-value]") + expect(rendered_component).to have_no_css(".op-work-package-card[draggable='true']") end end diff --git a/modules/backlogs/spec/components/backlogs/story_points_component_spec.rb b/modules/backlogs/spec/components/backlogs/story_points_component_spec.rb index 54ce4576f7f4..e8a0e8db5b94 100644 --- a/modules/backlogs/spec/components/backlogs/story_points_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/story_points_component_spec.rb @@ -49,6 +49,14 @@ expect(page).to have_css(".sr-only", text: "5 story points") end + it "positions the story points wrapper for the screen reader label" do + work_package = create(:work_package, project:, story_points: 5) + + render_inline(described_class.new(work_package:)) + + expect(page).to have_css(".position-relative .sr-only", text: "5 story points") + end + it "renders zero when story points are unset" do work_package = create(:work_package, project:, story_points: nil) diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_component_spec.rb index 860f882ff91c..f4d711a87dee 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_component_spec.rb @@ -71,6 +71,12 @@ src: menu_src end + it "forwards extra system arguments to the common card root" do + rendered = render_inline(described_class.new(work_package:, menu_src:, data: { controller: "backlogs--story" })) + + expect(rendered).to have_css("article[data-controller='backlogs--story']") + end + it "supports inline menu items through the menu slot" do rendered = render_inline(described_class.new(work_package:, menu_src:)) do |card| card.with_menu(button_aria_label: "Backlogs card actions") do |menu| diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb index f4f11a3f27ec..f0893040e4dc 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb @@ -105,16 +105,23 @@ def render_component(work_packages:, container:, drag_and_drop:) work_package = work_packages.first expect(rendered_component).to have_css( - ".Box-row#work_package_#{work_package.id}[data-controller='backlogs--story']" + ".Box-row#work_package_#{work_package.id}[data-controller='sortable-lists--item'] " \ + ".op-work-package-card[data-controller='backlogs--story']" ) end - it "renders Backlogs-specific row data attributes" do + it "renders Backlogs-specific row and card data attributes" do work_package = work_packages.first expect(rendered_component).to have_css(".Box-row#work_package_#{work_package.id}") do |row| - expect(row["data-story"]).to be_present - expect(row["data-backlogs--story-id-value"]).to eq(work_package.id.to_s) + expect(row["data-sortable-lists--item-id-value"]).to eq(work_package.id.to_s) + expect(row["data-sortable-lists--item-type-value"]).to eq("work_package") + end + + expect(rendered_component).to have_css(".Box-row#work_package_#{work_package.id} .op-work-package-card") do |card| + expect(card["data-story"]).to be_present + expect(card["data-backlogs--story-id-value"]).to eq(work_package.id.to_s) + expect(card["data-sortable-lists--item-target"]).to eq("preview handle") end end @@ -269,23 +276,22 @@ def render_component(work_packages:, container:, drag_and_drop:) describe "drag-and-drop data merging" do context "without drag_and_drop" do it "does not emit drag-and-drop data" do - expect(rendered_component).to have_no_css(".Box[data-generic-drag-and-drop-target]") - expect(rendered_component).to have_no_css(".Box[data-target-id]") - expect(rendered_component).to have_no_css(".Box[data-target-allowed-drag-type]") + expect(rendered_component).to have_no_css(".Box[data-sortable-lists-target]") + expect(rendered_component).to have_no_css(".Box[data-sortable-lists-list-type]") + expect(rendered_component).to have_no_css(".Box[data-sortable-lists-list-id]") end end context "with drag_and_drop configured" do let(:drag_and_drop) do - { target_id: "sprint:#{sprint.id}", allowed_drag_type: "story" } + { list_type: "sprint", list_id: sprint.id } end it "merges drag-and-drop data attributes onto the box" do expect(rendered_component).to have_css(".Box") do |box| - expect(box["data-generic-drag-and-drop-target"]).to eq("container") - expect(box["data-target-container-accessor"]).to eq(":scope > ul") - expect(box["data-target-id"]).to eq("sprint:#{sprint.id}") - expect(box["data-target-allowed-drag-type"]).to eq("story") + expect(box["data-sortable-lists-target"]).to eq("list") + expect(box["data-sortable-lists-list-type"]).to eq("sprint") + expect(box["data-sortable-lists-list-id"]).to eq(sprint.id.to_s) end end end diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb index b161c78c2eb2..d417f729da99 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb @@ -64,29 +64,27 @@ end describe "#row_args" do - it "marks the row as clickable and controlled by the Backlogs story controller" do + it "marks the row as clickable and wires it as the Backlogs draggable item" do expect(item.row_args[:classes]).to include( "Box-row--hover-blue", "Box-row--focus-gray", "Box-row--clickable" ) + expect(item.row_args[:test_selector]).to eq("work-package-#{work_package.id}") expect(item.row_args[:data]).to include( - story: true, - controller: "backlogs--story", - backlogs__story_id_value: work_package.id, - backlogs__story_display_id_value: work_package.display_id, - backlogs__story_full_url_value: work_package_path(work_package), - backlogs__story_selected_class: "Box-row--blue" + controller: "sortable-lists--item", + sortable_lists__item_id_value: work_package.id, + sortable_lists__item_type_value: "work_package", + sortable_lists__item_move_url_value: + a_string_ending_with(move_project_backlogs_work_package_path(project, work_package)) ) - expect(item.row_args[:test_selector]).to eq("work-package-#{work_package.id}") + expect(item.row_args[:draggable]).to be(true) + expect(item.row_args).not_to include(:tabindex) end it "marks the row as draggable for users allowed to manage sprint items" do expect(item.row_args[:classes]).to include("Box-row--draggable") - expect(item.row_args[:data]).to include( - draggable_id: work_package.id, - draggable_type: "story" - ) + expect(item.row_args[:data]).not_to include(:sortable_lists_prev_item_id) end context "when the user cannot manage sprint items" do @@ -98,18 +96,27 @@ it "does not mark the row as draggable" do expect(item.row_args[:classes]).not_to include("Box-row--draggable") - expect(item.row_args[:data]).not_to include(:draggable_id) - expect(item.row_args[:data]).not_to include(:drop_url) + expect(item.row_args[:data]).not_to include(:sortable_lists_prev_item_id) + expect(item.row_args[:data]).not_to include(:sortable_lists__item_move_url_value) + expect(item.row_args).not_to include(:draggable) + expect(item.row_args).not_to include(:tabindex) + end + + it "keeps the card focusable so keyboard users can interact with it" do + expect(render_inline(item.card)).to have_css(".op-work-package-card[tabindex='0']") end end end describe "URL derivation by container" do context "with a sprint container" do - it "sets drop_url and split_url" do - expect(item.row_args.dig(:data, :backlogs__story_split_url_value)) + it "uses the shared move route" do + render_inline(item.card) + card = page.find(".op-work-package-card") + + expect(card["data-backlogs--story-split-url-value"]) .to end_with(project_backlogs_backlog_details_path(project, work_package)) - expect(item.row_args.dig(:data, :drop_url)) + expect(item.row_args[:data][:sortable_lists__item_move_url_value]) .to end_with(move_project_backlogs_work_package_path(project, work_package)) end end @@ -117,8 +124,8 @@ context "with a backlog bucket container" do let(:container) { backlog_bucket } - it "sets drop_url" do - expect(item.row_args.dig(:data, :drop_url)) + it "uses the shared move route" do + expect(item.row_args[:data][:sortable_lists__item_move_url_value]) .to end_with(move_project_backlogs_work_package_path(project, work_package)) end end @@ -126,8 +133,8 @@ context "with an inbox container id" do let(:container) { "inbox_project_#{project.id}" } - it "sets drop_url" do - expect(item.row_args.dig(:data, :drop_url)) + it "uses the shared move route" do + expect(item.row_args[:data][:sortable_lists__item_move_url_value]) .to end_with(move_project_backlogs_work_package_path(project, work_package)) end end @@ -135,9 +142,12 @@ context "with params" do let(:params) { { all: 1 } } - it "passes params into row URLs" do - expect(item.row_args.dig(:data, :backlogs__story_split_url_value)).to match(/all=1/) - expect(item.row_args.dig(:data, :drop_url)).to match(/all=1/) + it "passes params into card URLs" do + render_inline(item.card) + card = page.find(".op-work-package-card") + + expect(card["data-backlogs--story-split-url-value"]).to match(/all=1/) + expect(item.row_args[:data][:sortable_lists__item_move_url_value]).to match(/all=1/) end end end @@ -150,6 +160,31 @@ expect(rendered_card).to have_css(".sr-only", text: "5 story points") end + it "applies the Box-card class to the card" do + expect(rendered_card).to have_css(".op-work-package-card.Box-card") + end + + it "wires the card as a Backlogs story" do + expect(rendered_card).to have_css( + ".op-work-package-card[data-controller~='backlogs--story']" \ + "[data-backlogs--story-id-value='#{work_package.id}']" \ + "[data-backlogs--story-display-id-value='#{work_package.display_id}']" \ + "[data-backlogs--story-full-url-value='#{work_package_path(work_package)}']" \ + "[data-backlogs--story-selected-class='Box-row--blue']" \ + "[data-sortable-lists--item-target='preview handle']" \ + "[tabindex='0']" + ) + end + + it "does not wire the card as the draggable item" do + expect(rendered_card).to have_css( + ".op-work-package-card[data-controller~='backlogs--story']" + ) + expect(rendered_card).to have_no_css(".op-work-package-card[data-controller~='sortable-lists--item']") + expect(rendered_card).to have_no_css(".op-work-package-card[data-sortable-lists--item-move-url-value]") + expect(rendered_card).to have_no_css(".op-work-package-card[draggable='true']") + end + it "supports caller-provided metric content through the item" do item.with_metric { "Custom metric" } diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb index c24776a2eec1..8c5ed24792a1 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb @@ -217,6 +217,13 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, expect(page).to have_octicon(:"move-to-bottom") end + it "carries the work package's current sprint as list_type and list_id on reorder items" do + render_component + + expect(page).to have_field("list_type", type: :hidden, with: "sprint", count: 2) + expect(page).to have_field("list_id", type: :hidden, with: sprint.id.to_s, count: 2) + end + context "when item is first" do let(:work_package) { enrich_with_neighbours(first_story) } @@ -366,6 +373,7 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_inbox\z/) expect(page).to have_octicon(:inbox) expect(page).to have_text(I18n.t(:"backlogs.work_package_card_menu_component.action_menu.move_to_inbox")) + expect(page).to have_field("list_type", type: :hidden, with: "inbox") end end diff --git a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb index c3100672c68d..ac7de68922e5 100644 --- a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb +++ b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb @@ -106,13 +106,14 @@ def turbo_stream_template(action:) let(:work_package_in_sprint) { create(:work_package, status:, sprint:, project:) } let(:project_id) { project.id } let(:id) { work_package_in_sprint.id } - let(:target_id) { nil } + let(:list_type) { nil } + let(:list_id) { nil } let(:prev_id) { nil } let(:all) { nil } let(:direction) { nil } subject do - put :move, params: { project_id:, id:, target_id:, prev_id:, all:, direction: }, format: :turbo_stream + put :move, params: { project_id:, id:, list_type:, list_id:, prev_id:, all:, direction: }, format: :turbo_stream end context "with a Sprint as source" do @@ -129,7 +130,8 @@ def turbo_stream_template(action:) end context "with the same Sprint as target" do - let(:target_id) { "sprint:#{sprint.id}" } + let(:list_type) { "sprint" } + let(:list_id) { sprint.id } it "replaces the sprint component once and emits no flash", :aggregate_failures do subject @@ -150,7 +152,8 @@ def turbo_stream_template(action:) context "with another Sprint as target" do let(:other_sprint) { create(:sprint, name: "Agile Sprint 2", project:) } - let(:target_id) { "sprint:#{other_sprint.id}" } + let(:list_type) { "sprint" } + let(:list_id) { other_sprint.id } it "responds with success and moves work_package to another Sprint", :aggregate_failures do subject @@ -172,9 +175,23 @@ def turbo_stream_template(action:) end end + context "with a blank prev_id (move to the top of the target list)" do + let(:other_sprint) { create(:sprint, name: "Agile Sprint 2", project:) } + let!(:existing_target_items) { create_list(:work_package, 2, project:, status:, sprint: other_sprint) } + let(:list_type) { "sprint" } + let(:list_id) { other_sprint.id } + let(:prev_id) { "" } + + it "moves the work_package to the first position" do + subject + + expect(work_package_in_sprint.reload).to have_attributes(sprint: other_sprint, position: 1) + end + end + context "with Inbox as target" do let!(:existing_inbox_item) { create(:work_package, project:, status:, position: 1) } - let(:target_id) { "inbox" } + let(:list_type) { "inbox" } let(:prev_id) { existing_inbox_item.id } it "replaces the sprint and backlog components without a flash", :aggregate_failures do @@ -218,7 +235,8 @@ def turbo_stream_template(action:) context "with a Backlog Bucket as target" do let(:bucket) { create(:backlog_bucket, name: "My Bucket", project:) } let!(:bucket_items) { create_list(:work_package, 2, project:, status:, backlog_bucket: bucket) } - let(:target_id) { "backlog_bucket:#{bucket.id}" } + let(:list_type) { "backlog_bucket" } + let(:list_id) { bucket.id } let(:prev_id) { bucket_items.first.id } it "replaces the sprint and backlog components without a flash", :aggregate_failures do @@ -300,7 +318,8 @@ def turbo_stream_template(action:) context "with a Sprint as target" do let(:target_sprint) { create(:sprint, name: "Target Sprint", project:) } - let(:target_id) { "sprint:#{target_sprint.id}" } + let(:list_type) { "sprint" } + let(:list_id) { target_sprint.id } it "replaces inbox and target sprint components without a flash", :aggregate_failures do subject @@ -325,7 +344,7 @@ def turbo_stream_template(action:) context "with the same Inbox as target" do let!(:inbox_items) { create_list(:work_package, 5, project:, status:) } - let(:target_id) { "inbox" } + let(:list_type) { "inbox" } let(:prev_id) { inbox_items.first.id } it "replaces only the inbox component without a flash", :aggregate_failures do @@ -348,7 +367,8 @@ def turbo_stream_template(action:) context "with a Backlog Bucket as target" do let(:bucket) { create(:backlog_bucket, project:) } let!(:bucket_items) { create_list(:work_package, 2, project:, status:, backlog_bucket: bucket) } - let(:target_id) { "backlog_bucket:#{bucket.id}" } + let(:list_type) { "backlog_bucket" } + let(:list_id) { bucket.id } let(:prev_id) { bucket_items.first.id } it "replaces only the backlog component without a flash", :aggregate_failures do @@ -398,7 +418,8 @@ def turbo_stream_template(action:) context "with a Sprint as target" do let(:target_sprint) { create(:sprint, name: "Target Sprint", project:) } - let(:target_id) { "sprint:#{target_sprint.id}" } + let(:list_type) { "sprint" } + let(:list_id) { target_sprint.id } it "replaces the backlog and sprint components without a flash", :aggregate_failures do subject @@ -422,7 +443,7 @@ def turbo_stream_template(action:) context "with the Inbox as target" do let!(:existing_inbox_item) { create(:work_package, project:, status:, position: 1) } - let(:target_id) { "inbox" } + let(:list_type) { "inbox" } let(:prev_id) { existing_inbox_item.id } it "replaces only the backlog component without a flash", :aggregate_failures do @@ -444,7 +465,8 @@ def turbo_stream_template(action:) end context "with the same Backlog Bucket as target" do - let(:target_id) { "backlog_bucket:#{bucket.id}" } + let(:list_type) { "backlog_bucket" } + let(:list_id) { bucket.id } let(:prev_id) { bucket_items.first.id } it "replaces only the backlog component without a flash", :aggregate_failures do @@ -468,7 +490,8 @@ def turbo_stream_template(action:) context "with another Backlog Bucket as target" do let(:other_bucket) { create(:backlog_bucket, project:) } let!(:other_bucket_items) { create_list(:work_package, 2, project:, status:, backlog_bucket: other_bucket) } - let(:target_id) { "backlog_bucket:#{other_bucket.id}" } + let(:list_type) { "backlog_bucket" } + let(:list_id) { other_bucket.id } let(:prev_id) { other_bucket_items.first.id } it "replaces only the backlog component without a flash", :aggregate_failures do @@ -512,7 +535,8 @@ def turbo_stream_template(action:) context "when service call fails" do let(:other_sprint) { create(:sprint, name: "Agile Sprint 2", project:) } - let(:target_id) { "sprint:#{other_sprint.id}" } + let(:list_type) { "sprint" } + let(:list_id) { other_sprint.id } let(:service_result) { ServiceResult.failure(message: "Something went wrong") } before do @@ -782,24 +806,24 @@ def turbo_stream_template(action:) expect(response).to have_turbo_stream action: "dialog" end - it "includes the existing sprints in the target_id" do + it "includes the existing sprints as list_id options" do subject displayed_sprints.each do |sprint| - expect(response.body).to include("sprint:#{sprint.id}") + expect(response.body).to include(%(value="#{sprint.id}")) end end - it "does not include the current sprints from the target_id" do + it "does not include the current sprint as a list_id option" do subject - expect(response.body).not_to include("sprint:#{sprint.id}") + expect(response.body).not_to include(%(value="#{sprint.id}")) end it "does not include the other sprint" do subject - expect(response.body).not_to include("sprint:#{other_sprint.id}") + expect(response.body).not_to include(%(value="#{other_sprint.id}")) end end @@ -821,18 +845,18 @@ def turbo_stream_template(action:) expect(response.body).not_to include("sprints") end - it "includes the existing sprints in the target_id" do + it "includes the existing sprints as list_id options" do subject displayed_sprints.each do |sprint| - expect(response.body).to include("sprint:#{sprint.id}") + expect(response.body).to include(%(value="#{sprint.id}")) end end it "does not include the other sprint" do subject - expect(response.body).not_to include("sprint:#{other_sprint.id}") + expect(response.body).not_to include(%(value="#{other_sprint.id}")) end end @@ -852,14 +876,14 @@ def turbo_stream_template(action:) subject displayed_sprints.each do |sprint| - expect(response.body).to include("sprint:#{sprint.id}") + expect(response.body).to include(%(value="#{sprint.id}")) end end it "does not include sprints from other projects" do subject - expect(response.body).not_to include("sprint:#{other_sprint.id}") + expect(response.body).not_to include(%(value="#{other_sprint.id}")) end end @@ -908,18 +932,18 @@ def turbo_stream_template(action:) expect(response).to have_turbo_stream action: "dialog" end - it "includes the project buckets in the target_id options" do + it "includes the project buckets as list_id options" do subject displayed_buckets.each do |bucket| - expect(response.body).to include("backlog_bucket:#{bucket.id}") + expect(response.body).to include(%(value="#{bucket.id}")) end end it "does not include buckets from other projects" do subject - expect(response.body).not_to include("backlog_bucket:#{other_bucket.id}") + expect(response.body).not_to include(%(value="#{other_bucket.id}")) end end @@ -938,14 +962,14 @@ def turbo_stream_template(action:) it "excludes the current bucket from the options" do subject - expect(response.body).not_to include("backlog_bucket:#{current_bucket.id}") + expect(response.body).not_to include(%(value="#{current_bucket.id}")) end it "includes the other project buckets" do subject displayed_buckets.each do |bucket| - expect(response.body).to include("backlog_bucket:#{bucket.id}") + expect(response.body).to include(%(value="#{bucket.id}")) end end end diff --git a/modules/backlogs/spec/features/backlogs/edit_spec.rb b/modules/backlogs/spec/features/backlogs/edit_spec.rb index c2dd66908232..2f874c02b1e5 100644 --- a/modules/backlogs/spec/features/backlogs/edit_spec.rb +++ b/modules/backlogs/spec/features/backlogs/edit_spec.rb @@ -168,6 +168,27 @@ end end + context "when moving work packages from sprints" do + describe "moving to a different sprint" do + it "moves a work package to a different sprint" do + planning_page.expect_work_package_in_sprint(work_package, first_sprint) + + planning_page.click_in_work_package_move_submenu(work_package, "Move to sprint", wait: false) + + within_modal "Move to sprint" do + expect(page).to have_no_select("list_id", with_options: [first_sprint.name]) + expect(page).to have_select("list_id", with_options: [second_sprint.name]) + + select second_sprint.name, from: "list_id" + click_on "Move" + end + + planning_page.expect_work_package_not_in_sprint(work_package, first_sprint) + planning_page.expect_work_package_in_sprint(work_package, second_sprint) + end + end + end + context "without the necessary permissions" do let(:permissions) { all_permissions - %i[create_sprints start_complete_sprint] } diff --git a/modules/backlogs/spec/features/inbox_column_spec.rb b/modules/backlogs/spec/features/inbox_column_spec.rb index 0ada0f71b26e..f5b89637637f 100644 --- a/modules/backlogs/spec/features/inbox_column_spec.rb +++ b/modules/backlogs/spec/features/inbox_column_spec.rb @@ -268,9 +268,9 @@ within_modal "Move to sprint" do # Expect to have all sprints listed - expect(page).to have_select("target_id", with_options: ["Sprint 1", "Sprint 2"]) + expect(page).to have_select("list_id", with_options: ["Sprint 1", "Sprint 2"]) - select sprint.name, from: "target_id" + select sprint.name, from: "list_id" click_button "Move" end @@ -284,8 +284,8 @@ planning_page.click_in_work_package_move_submenu(inbox_wp1, "Move to sprint", wait: false) within_modal "Move to sprint" do - expect(page).to have_select("target_id", with_options: ["Sprint 1", "Sprint 2"]) - select sprint.name, from: "target_id" + expect(page).to have_select("list_id", with_options: ["Sprint 1", "Sprint 2"]) + select sprint.name, from: "list_id" # Before saving the selection, simulate that another user completed the sprint sprint.completed! @@ -305,7 +305,7 @@ end end - describe "moving backlog items to a sprint via drag-and-drop" do + describe "moving backlog items to a sprint via drag-and-drop", :selenium do it "moves multiple items into the sprint one by one" do planning_page.drag_work_package_to_sprint(inbox_wp1, sprint) planning_page.expect_no_inbox_item(inbox_wp1) @@ -322,7 +322,13 @@ planning_page.expect_work_package_in_sprint(inbox_wp3, sprint) end - context "with real authentication and a private project" do + context "with real authentication and a private project", + with_settings: { + "plugin_openproject_two_factor_authentication" => { + "active_strategies" => [], + "disabled" => true + } + } do let!(:project) do create(:private_project, types: [type], @@ -389,7 +395,7 @@ end end - describe "moving sprint items back to the inbox via drag-and-drop" do + describe "moving sprint items back to the inbox via drag-and-drop", :selenium do let!(:sprint_wp1) { create(:work_package, project:, sprint:) } let!(:sprint_wp2) { create(:work_package, project:, sprint:) } @@ -444,13 +450,13 @@ planning_page.visit! end - it "retains the expanded inbox across all update actions", :aggregate_failures do + it "retains the expanded inbox across all update actions", :aggregate_failures, :selenium do # Initial load shows pagination planning_page.expect_inbox_show_more # Expand inbox — URL advances to ?all=1 planning_page.click_inbox_show_more - expect(page.current_url).to include("all=1") + expect(page).to have_current_path(/all=1/) planning_page.expect_no_inbox_show_more # Drag an inbox item to the sprint @@ -468,7 +474,7 @@ # Move an inbox item to the sprint via the dialog planning_page.click_in_work_package_move_submenu(inbox_items.last, "Move to sprint", wait: false) within_modal "Move to sprint" do - select sprint.name, from: "target_id" + select sprint.name, from: "list_id" click_button "Move" end planning_page.expect_no_inbox_show_more diff --git a/modules/backlogs/spec/features/work_packages/drag_in_bucket_spec.rb b/modules/backlogs/spec/features/work_packages/drag_in_bucket_spec.rb index 66fb9de53575..b776f15b525a 100644 --- a/modules/backlogs/spec/features/work_packages/drag_in_bucket_spec.rb +++ b/modules/backlogs/spec/features/work_packages/drag_in_bucket_spec.rb @@ -31,7 +31,7 @@ require "spec_helper" require_relative "../../support/pages/backlog" -RSpec.describe "Dragging work packages in backlog buckets", :js do +RSpec.describe "Dragging work packages in backlog buckets", :js, :selenium do create_shared_association_defaults_for_work_package_factory shared_let(:project) do @@ -71,6 +71,49 @@ ) end + it "reorders a work package to the first position in a bucket" do + backlogs_page.visit! + + backlogs_page.drag_work_package(alpha_wp3, before: alpha_wp1) + + backlogs_page.expect_work_packages_in_backlog_bucket_in_order( + bucket_alpha, work_packages: [alpha_wp3, alpha_wp1, alpha_wp2] + ) + end + + it "does not move the first work package to the end when it is picked up and released" do + backlogs_page.visit! + + backlogs_page.expect_work_packages_in_backlog_bucket_in_order( + bucket_alpha, work_packages: [alpha_wp1, alpha_wp2, alpha_wp3] + ) + + backlogs_page.pick_up_and_release_work_package(alpha_wp1) + + backlogs_page.expect_backlogs_drop_handled_without_item_target + backlogs_page.expect_no_backlogs_move_request + backlogs_page.expect_work_packages_in_backlog_bucket_in_order( + bucket_alpha, work_packages: [alpha_wp1, alpha_wp2, alpha_wp3] + ) + end + + context "when the bucket item was morphed by a Turbo update" do + it "allows dragging the morphed item" do + backlogs_page.visit! + + backlogs_page.click_in_inbox_move_menu(alpha_wp2, "Move down") + backlogs_page.expect_work_packages_in_backlog_bucket_in_order( + bucket_alpha, work_packages: [alpha_wp1, alpha_wp3, alpha_wp2] + ) + + backlogs_page.drag_work_package(alpha_wp2, before: alpha_wp1) + + backlogs_page.expect_work_packages_in_backlog_bucket_in_order( + bucket_alpha, work_packages: [alpha_wp2, alpha_wp1, alpha_wp3] + ) + end + end + it "moves a work package into another bucket" do backlogs_page.visit! diff --git a/modules/backlogs/spec/features/work_packages/drag_in_inbox_spec.rb b/modules/backlogs/spec/features/work_packages/drag_in_inbox_spec.rb index 6d18dd70d4e1..1d523255ec98 100644 --- a/modules/backlogs/spec/features/work_packages/drag_in_inbox_spec.rb +++ b/modules/backlogs/spec/features/work_packages/drag_in_inbox_spec.rb @@ -32,7 +32,7 @@ require_relative "../../support/pages/backlog" RSpec.describe "Dragging work packages in the inbox", - :js do + :js, :selenium do create_shared_association_defaults_for_work_package_factory shared_let(:project) { create(:project) } @@ -120,6 +120,27 @@ end end + context "when the item was morphed by a Turbo update" do + it "allows dragging a morphed inbox item" do + backlogs_page.visit! + + backlogs_page.click_in_inbox_move_menu(inbox_wp2, "Move down") + backlogs_page.expect_work_packages_in_inbox_in_order(work_packages: [inbox_wp1, + inbox_wp3, + inbox_wp2, + inbox_wp4, + inbox_wp5]) + + backlogs_page.drag_work_package(inbox_wp2, before: inbox_wp5) + + backlogs_page.expect_work_packages_in_inbox_in_order(work_packages: [inbox_wp1, + inbox_wp3, + inbox_wp4, + inbox_wp2, + inbox_wp5]) + end + end + context "when lacking the permission to manage sprint items" do current_user do create(:user, diff --git a/modules/backlogs/spec/features/work_packages/drag_in_sprint_spec.rb b/modules/backlogs/spec/features/work_packages/drag_in_sprint_spec.rb index 919bd3b48de5..6e4f4debc5db 100644 --- a/modules/backlogs/spec/features/work_packages/drag_in_sprint_spec.rb +++ b/modules/backlogs/spec/features/work_packages/drag_in_sprint_spec.rb @@ -32,7 +32,7 @@ require_relative "../../support/pages/backlog" RSpec.describe "Dragging work packages in and between sprints", - :js, :settings_reset do + :js, :selenium, :settings_reset do let!(:project) do create(:project, types: [type], @@ -65,6 +65,9 @@ let!(:sprint1_other_project_wp1) { create(:work_package, sprint: sprint1, type:, project: project2) } let!(:sprint1_other_project_wp2) { create(:work_package, sprint: sprint1, type:, project: project2) } let!(:sprint1_other_project_wp3) { create(:work_package, sprint: sprint1, type:, project: project2) } + let!(:bucket) { create(:backlog_bucket, project:, name: "Backlog bucket") } + let!(:bucket_wp1) { create(:work_package, backlog_bucket: bucket, position: 1, type:, project:) } + let!(:bucket_wp2) { create(:work_package, backlog_bucket: bucket, position: 2, type:, project:) } let(:backlogs_page) { Pages::Backlog.new(project) } @@ -119,6 +122,44 @@ .expect_work_packages_in_sprint_in_order(sprint2, work_packages: [sprint1_wp1]) end + + context "when the sprint item was morphed by a Turbo update" do + it "allows dragging the morphed item" do + backlogs_page.click_in_sprint_story_move_menu(sprint1_wp2, "Move down") + backlogs_page.expect_work_packages_in_sprint_in_order(sprint1, + work_packages: [sprint1_wp1, + sprint1_wp3, + sprint1_wp2, + sprint1_wp4]) + + backlogs_page.drag_work_package(sprint1_wp2, before: sprint1_wp1) + + backlogs_page.expect_work_packages_in_sprint_in_order(sprint1, + work_packages: [sprint1_wp2, + sprint1_wp1, + sprint1_wp3, + sprint1_wp4]) + end + end + + it "keeps drop indicators active after moving a bucket item into the sprint" do + backlogs_page.drag_work_package(bucket_wp2, before: sprint1_wp4) + backlogs_page.expect_work_packages_in_sprint_in_order( + sprint1, + work_packages: [sprint1_wp1, sprint1_wp2, sprint1_wp3, bucket_wp2, sprint1_wp4] + ) + + backlogs_page.drag_work_package(sprint1_wp1, before: sprint1_wp3) + + dnd_probe_state = page.evaluate_script(<<~JS) + window.__opBacklogsDndProbeState + JS + drop_positions = dnd_probe_state.fetch("events").flat_map { |event| event.fetch("dropPositions") } + + expect(drop_positions) + .to include({ "itemId" => sprint1_wp3.id.to_s, "position" => "top" }), + JSON.pretty_generate(dnd_probe_state) + end end context "when lacking the permission to manage sprint items" do diff --git a/modules/backlogs/spec/features/work_packages/move_to_backlog_spec.rb b/modules/backlogs/spec/features/work_packages/move_to_backlog_spec.rb index 8da8d7a27284..39f760988c22 100644 --- a/modules/backlogs/spec/features/work_packages/move_to_backlog_spec.rb +++ b/modules/backlogs/spec/features/work_packages/move_to_backlog_spec.rb @@ -88,7 +88,7 @@ planning_page.click_in_work_package_move_submenu(work_package, "Move to backlog bucket") within_modal "Move to backlog bucket" do - select bucket_b.name, from: "target_id" + select bucket_b.name, from: "list_id" click_on "Move" end @@ -107,7 +107,7 @@ planning_page.click_in_work_package_move_submenu(work_package, "Move to backlog bucket") within_modal "Move to backlog bucket" do - select bucket_a.name, from: "target_id" + select bucket_a.name, from: "list_id" click_on "Move" end @@ -154,10 +154,10 @@ planning_page.click_in_work_package_move_submenu(work_package, "Move to sprint", wait: false) within_modal "Move to sprint" do - expect(page).to have_no_select("target_id", with_options: [sprint.name]) - expect(page).to have_select("target_id", with_options: [second_sprint.name]) + expect(page).to have_no_select("list_id", with_options: [sprint.name]) + expect(page).to have_select("list_id", with_options: [second_sprint.name]) - select second_sprint.name, from: "target_id" + select second_sprint.name, from: "list_id" click_on "Move" end @@ -176,7 +176,7 @@ planning_page.click_in_work_package_move_submenu(work_package, "Move to sprint") within_modal "Move to sprint" do - select sprint.name, from: "target_id" + select sprint.name, from: "list_id" click_on "Move" end @@ -195,7 +195,7 @@ planning_page.click_in_work_package_move_submenu(work_package, "Move to sprint", wait: false) within_modal "Move to sprint" do - select sprint.name, from: "target_id" + select sprint.name, from: "list_id" click_on "Move" end diff --git a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb index 8cc75f785079..691defb4f6b1 100644 --- a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb +++ b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb @@ -86,6 +86,12 @@ expect(response).to render_template("backlogs/backlog/_backlog_list") expect(response).to have_turbo_frame "backlogs_container" + expect(response.body).to include('class="op-sprint-planning-container"') + expect(response.body).to include('data-controller="sortable-lists"') + expect(response.body).to include('data-sortable-lists-accepted-type-value="work_package"') + expect(response.body).to include('id="owner_backlogs_container"') + expect(response.body).to include('id="sprint_backlogs_container"') + expect(response.body.scan('data-sortable-lists-target="scrollable"').size).to eq(2) expect(response).to have_no_turbo_frame "content-bodyRight" end @@ -105,6 +111,29 @@ expect(response.body).to include('id="sprint_backlogs_container"') end end + + it "uses the inbox border box as a backlogs list target" do + get "/projects/#{project.identifier}/backlogs/backlog", headers: { "Turbo-Frame" => "backlogs_container" } + + expect(response).to have_http_status(:ok) + expect(response.body).to include(%(id="inbox_project_#{project.id}")) + expect(response.body).to include('data-sortable-lists-target="list"') + expect(response.body).to include('data-sortable-lists-list-type="inbox"') + expect(response.body).not_to include('data-sortable-lists-list-id="inbox"') + end + + context "with backlog buckets" do + shared_let(:backlog_bucket) { create(:backlog_bucket, project:) } + + it "uses each backlog bucket border box as a backlogs list target" do + get "/projects/#{project.identifier}/backlogs/backlog", headers: { "Turbo-Frame" => "backlogs_container" } + + expect(response).to have_http_status(:ok) + expect(response.body).to include(%(data-test-selector="backlog-bucket-#{backlog_bucket.id}")) + expect(response.body).to include('data-sortable-lists-list-type="backlog_bucket"') + expect(response.body).to include(%(data-sortable-lists-list-id="#{backlog_bucket.id}")) + end + end end end diff --git a/modules/backlogs/spec/services/backlogs/work_packages/update_service_spec.rb b/modules/backlogs/spec/services/backlogs/work_packages/update_service_spec.rb index d3c410002892..32cd22bcc948 100644 --- a/modules/backlogs/spec/services/backlogs/work_packages/update_service_spec.rb +++ b/modules/backlogs/spec/services/backlogs/work_packages/update_service_spec.rb @@ -59,50 +59,46 @@ end end - context "with neither target_id nor direction" do + context "with neither list_type nor direction" do it_behaves_like "returns failure without delegating", {}, "backlogs.stories.update_service.missing_target" end - context "with both target_id and direction" do + context "with both list_type and direction" do it_behaves_like "returns failure without delegating", - { target_id: "inbox", direction: "highest" }, + { list_type: "inbox", direction: "highest" }, "backlogs.stories.update_service.ambiguous_target" end - context "when target_id contains an invalid type and id" do - it_behaves_like "returns failure without delegating", { target_id: "unknown:42" } + context "when list_type is an invalid type with a list_id" do + it_behaves_like "returns failure without delegating", { list_type: "unknown", list_id: "42" } end - context "when target_id contains an invalid type and no id" do - it_behaves_like "returns failure without delegating", { target_id: "unknown" } + context "when list_type is an invalid type without a list_id" do + it_behaves_like "returns failure without delegating", { list_type: "unknown" } end - %w[sprint backlog_bucket].each do |target_type| - context "when target_id is '#{target_type}' missing the colon and id" do - it_behaves_like "returns failure without delegating", { target_id: target_type } + %w[sprint backlog_bucket].each do |list_type| + context "when list_type is '#{list_type}' with no list_id" do + it_behaves_like "returns failure without delegating", { list_type: } end - context "when target_id is '#{target_type}:' missing id" do - it_behaves_like "returns failure without delegating", { target_id: "#{target_type}:" } + context "when list_type is '#{list_type}' with a blank list_id" do + it_behaves_like "returns failure without delegating", { list_type:, list_id: "" } end - context "when target_id is '#{target_type}:unknown' having an invalid id" do - it_behaves_like "returns failure without delegating", { target_id: "#{target_type}:unknown" } + context "when list_type is '#{list_type}' with a non-numeric list_id" do + it_behaves_like "returns failure without delegating", { list_type:, list_id: "unknown" } end end - context "when target_id is 'inbox:' having an extra colon" do - it_behaves_like "returns failure without delegating", { target_id: "inbox:" } + context "when list_type is 'inbox' with a numeric list_id" do + it_behaves_like "returns failure without delegating", { list_type: "inbox", list_id: "1" } end - context "when target_id is 'inbox:1' having a valid id" do - it_behaves_like "returns failure without delegating", { target_id: "inbox:1" } - end - - context "when target_id is 'inbox:unknown' having an invalid id" do - it_behaves_like "returns failure without delegating", { target_id: "inbox:unknown" } + context "when list_type is 'inbox' with a non-numeric list_id" do + it_behaves_like "returns failure without delegating", { list_type: "inbox", list_id: "unknown" } end context "with an invalid direction" do @@ -120,25 +116,31 @@ end end - context "with target_id: sprint" do + context "with list_type: sprint" do it "delegates with sprint_id and nil backlog_bucket_id" do - instance.call(target_id: "sprint:42") + instance.call(list_type: "sprint", list_id: "42") + + expect(inner_service).to have_received(:call).with(sprint_id: "42", backlog_bucket_id: nil) + end + + it "normalizes integer list IDs before validating the target" do + instance.call(list_type: "sprint", list_id: 42) expect(inner_service).to have_received(:call).with(sprint_id: "42", backlog_bucket_id: nil) end end - context "with target_id: backlog_bucket" do + context "with list_type: backlog_bucket" do it "delegates with backlog_bucket_id and nil sprint_id" do - instance.call(target_id: "backlog_bucket:99") + instance.call(list_type: "backlog_bucket", list_id: "99") expect(inner_service).to have_received(:call).with(backlog_bucket_id: "99", sprint_id: nil) end end - context "with target_id: inbox" do + context "with list_type: inbox" do it "delegates with nil sprint_id and nil backlog_bucket_id" do - instance.call(target_id: "inbox") + instance.call(list_type: "inbox") expect(inner_service).to have_received(:call).with(backlog_bucket_id: nil, sprint_id: nil) end @@ -150,7 +152,7 @@ it "returns the failure without calling move_after", :aggregate_failures do allow(story).to receive(:move_after) - result = instance.call(target_id: "inbox") + result = instance.call(list_type: "inbox") expect(result).to be_failure expect(story).not_to have_received(:move_after) @@ -161,7 +163,7 @@ it "calls move_after with the prev_id on success" do allow(story).to receive(:move_after) - instance.call(target_id: "inbox", prev_id: "5") + instance.call(list_type: "inbox", prev_id: "5") expect(story).to have_received(:move_after).with(prev_id: "5") end @@ -171,7 +173,7 @@ it "calls move_after with the position on success" do allow(story).to receive(:move_after) - instance.call(target_id: "inbox", position: "3") + instance.call(list_type: "inbox", position: "3") expect(story).to have_received(:move_after).with(position: "3") end @@ -181,7 +183,7 @@ it "prefers prev_id over position" do allow(story).to receive(:move_after) - instance.call(target_id: "inbox", prev_id: "5", position: "3") + instance.call(list_type: "inbox", prev_id: "5", position: "3") expect(story).to have_received(:move_after).with(prev_id: "5") expect(story).not_to have_received(:move_after).with(position: "3") diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index 734a09d07908..e0fa0f5d600d 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -29,6 +29,7 @@ #++ require "support/pages/page" +require "json" module Pages class Backlog < Page @@ -45,7 +46,7 @@ def visit! expect(page).to have_css("turbo-frame#backlogs_container", wait: 10) expect(page).to have_css("#owner_backlogs_container", wait: 10) expect(page).to have_css("#sprint_backlogs_container", wait: 10) - wait_for_network_idle + wait_for_backlogs_network_idle end def path @@ -94,6 +95,10 @@ def expect_no_sprints_blankslate end end + alias_method :expect_backlog_blankslate, :expect_sprints_blankslate + alias_method :expect_backlog_blankslate_description, :expect_sprints_blankslate_description + alias_method :expect_no_backlog_blankslate, :expect_no_sprints_blankslate + def expect_backlog_settings_link within_sprint_backlogs do expect(page).to have_link( @@ -149,7 +154,7 @@ def expect_inbox_show_more end def expect_no_inbox_show_more - wait_for_network_idle + wait_for_backlogs_network_idle within_backlog_inbox do expect(page).to have_no_css("#inbox_project_#{project.id}_show_more") end @@ -159,7 +164,7 @@ def click_inbox_show_more within_backlog_inbox do find("#inbox_project_#{project.id}_show_more").click end - wait_for_network_idle + wait_for_backlogs_network_idle end def expect_work_packages_in_inbox_in_order(work_packages: []) @@ -187,7 +192,7 @@ def expect_work_packages_in_order(work_packages: []) selectors = work_packages.map { |wp| work_package_selector(wp) } expect(page) .to have_css(selectors.join(" + ")) - wait_for_network_idle + wait_for_backlogs_network_idle end def sprint_items_in_visual_order(sprint, *work_packages) @@ -306,12 +311,15 @@ def within_work_package_move_submenu(work_package, &) def click_in_work_package_move_submenu(work_package, item_name, wait: true) within_work_package_move_submenu(work_package) do |submenu| - wait_for_turbo_stream(wait:) do - submenu.find(:menuitem, text: item_name).click + wait_for_backlogs_turbo_stream(wait:) do + submenu.find(:menuitem, text: item_name, visible: :all).click end end end + alias_method :click_in_inbox_move_menu, :click_in_work_package_move_submenu + alias_method :click_in_sprint_story_move_menu, :click_in_work_package_move_submenu + def expect_no_backlog_bucket_menu(bucket) within_backlog_bucket(bucket) do expect(page).to have_no_button(accessible_name: "Backlog bucket actions") @@ -346,7 +354,7 @@ def expect_details_view(work_package) expect(page).to have_current_path project_backlogs_backlog_details_path(work_package.project, work_package), ignore_query: true - wait_for_network_idle + wait_for_backlogs_network_idle details_view end @@ -394,6 +402,46 @@ def expect_work_package_not_draggable(work_package) .to have_no_css(draggable_work_package_selector(work_package)) end + def pick_up_and_release_work_package(work_package) + loop do + moved_element = find(draggable_work_package_selector(work_package)) + + begin + install_backlogs_move_request_probe + pick_up_and_release_backlogs_item(moved_element) + return + rescue Capybara::Cuprite::ObsoleteNode + next + ensure + stop_backlogs_move_request_probe + end + end + end + + def expect_no_backlogs_move_request + move_requests = page.evaluate_script("window.__opBacklogsMoveRequestProbe?.requests ?? []") + + expect(move_requests).to be_empty + ensure + stop_backlogs_move_request_probe + end + + def expect_backlogs_drop_handled_without_item_target + drop_summary = page.evaluate_script(<<~JS) + (() => { + const call = window.__opBacklogsDndProbeState?.handleDropCalls?.at(-1); + + return { + handled: Boolean(call), + dropTargetTypes: call?.dropTargets?.map((target) => target.data?.entries?.type) ?? [] + }; + })() + JS + + expect(drop_summary.fetch("handled")).to be(true) + expect(drop_summary.fetch("dropTargetTypes")).not_to include("item") + end + def drag_work_package(moved, before: nil, into: nil) raise ArgumentError, "You must specify either before or into" unless before.present? ^ into.present? @@ -404,8 +452,8 @@ def drag_work_package(moved, before: nil, into: nil) find(sprint_selector(into)) end - wait_for_turbo_stream do - moved_element.native.drag_to(target_element.native, delay: 0.1) + wait_for_backlogs_turbo_stream do + drag_backlogs_item(source: moved_element, target: target_element, edge: before ? :top : nil) end rescue Capybara::Cuprite::ObsoleteNode retry @@ -413,11 +461,14 @@ def drag_work_package(moved, before: nil, into: nil) def drag_work_package_to_backlog_inbox(work_package) moved_element = find(draggable_work_package_selector(work_package)) - target_element = find(list_body_selector(backlog_inbox_selector)) + inbox = find(backlog_inbox_selector) + target_item = inbox.all("[data-sortable-lists--item-id-value]", minimum: 0).last - wait_for_turbo_stream do - moved_element.native.drag_to(target_element.native, delay: 0.1) + wait_for_backlogs_turbo_stream do + drag_backlogs_item(source: moved_element, target: target_item || inbox, edge: target_item ? :bottom : nil) end + wait_for { work_package.reload.backlog_bucket_id }.to be_nil + wait_for { work_package.reload.sprint_id }.to be_nil rescue Capybara::Cuprite::ObsoleteNode retry end @@ -426,9 +477,10 @@ def drag_work_package_to_backlog_bucket(work_package, bucket) moved_element = find(draggable_work_package_selector(work_package)) target_element = find(list_body_selector(bucket_selector(bucket))) - wait_for_turbo_stream do - moved_element.native.drag_to(target_element.native, delay: 0.1) + wait_for_backlogs_turbo_stream do + drag_backlogs_item(source: moved_element, target: target_element) end + wait_for { work_package.reload.backlog_bucket_id }.to eq(bucket.id) rescue Capybara::Cuprite::ObsoleteNode retry end @@ -436,9 +488,10 @@ def drag_work_package_to_backlog_bucket(work_package, bucket) def drag_work_package_to_sprint(work_package, sprint) moved_element = find(draggable_work_package_selector(work_package)) target_element = find(list_body_selector(sprint_selector(sprint))) - wait_for_turbo_stream do - moved_element.native.drag_to(target_element.native, delay: 0.1) + wait_for_backlogs_turbo_stream do + drag_backlogs_item(source: moved_element, target: target_element) end + wait_for { work_package.reload.sprint_id }.to eq(sprint.id) rescue Capybara::Cuprite::ObsoleteNode retry end @@ -564,7 +617,424 @@ def work_package_selector(work_package) end def draggable_work_package_selector(work_package) - "#{work_package_selector(work_package)}[data-draggable-id]" + "#{work_package_selector(work_package)}[data-sortable-lists--item-id-value]" + end + + def drag_backlogs_item(source:, target:, edge: nil) + if selenium_driver? + selenium_drag_backlogs_item(source:, target:, edge:) + else + source.native.drag_to(target.native, delay: 0.1) + end + end + + def pick_up_and_release_backlogs_item(source) + install_backlogs_dnd_probe(source:, target: source, edge: nil) + + scroll_to_element(source) + + if selenium_driver? + page + .driver + .browser + .action + .move_to(source.native) + .click_and_hold + .pause(duration: 0.1) + .move_by(0, 8) + .pause(duration: 0.1) + .release + .perform + else + source.native.drag_to(source.native, delay: 0.1) + end + + # Assert Pragmatic DnD tore down its own honey-pot overlay before we force + # a cleanup, so a regression that leaves the overlay stuck is caught here + # instead of being masked by the JS removal below. + expect(page).to have_no_css("[data-pdnd-honey-pot]", wait: 2, visible: :all) + clear_pragmatic_dnd_honey_pot + end + + def selenium_drag_backlogs_item(source:, target:, edge: nil) + install_backlogs_dnd_probe(source:, target:, edge:) + + scroll_to_element(source) + + source_rect = source.native.rect + target_rect = target.native.rect + target_x, target_y = selenium_target_point(target_rect, edge:) + source_x, source_y = selenium_element_center(source_rect) + + page + .driver + .browser + .action + .drag_and_drop_by(source.native, target_x - source_x, target_y - source_y) + .perform + + # Assert Pragmatic DnD tore down its own honey-pot overlay before we force + # a cleanup, so a regression that leaves the overlay stuck is caught here + # instead of being masked by the JS removal below. + expect(page).to have_no_css("[data-pdnd-honey-pot]", wait: 2, visible: :all) + clear_pragmatic_dnd_honey_pot + end + + def selenium_target_point(rect, edge:) + offset = [6, rect.height / 4].min + + [ + rect.x + (rect.width / 2), + case edge + when :top + rect.y + offset + when :bottom + rect.y + rect.height - offset + else + rect.y + (rect.height / 2) + end + ].map(&:round) + end + + def selenium_element_center(rect) + [ + rect.x + (rect.width / 2), + rect.y + (rect.height / 2) + ].map(&:round) + end + + def wait_for_backlogs_network_idle + wait_for_network_idle if using_cuprite? + end + + def wait_for_backlogs_turbo_stream(wait: 10, &) + return yield unless wait + return wait_for_turbo_stream(wait:, &) if using_cuprite? + + timeout = wait == true ? 10 : wait + timeout_ms = timeout * 1000 + page.execute_script(<<~JS, timeout_ms) + window.__opBacklogsTurboStreamAbort?.abort(); + + const controller = new AbortController(); + const state = { + rendered: false, + timeoutMs: arguments[0], + events: [] + }; + + document.addEventListener('op:turbo-stream-rendered', (event) => { + state.rendered = true; + state.events.push({ + type: event.type, + time: Math.round(performance.now()) + }); + }, { signal: controller.signal }); + + window.__opBacklogsTurboStreamAbort = controller; + window.__opBacklogsTurboStreamState = state; + JS + + yield + + wait_for_backlogs_turbo_stream_event(timeout:) + ensure + stop_backlogs_turbo_stream_probe unless using_cuprite? + end + + def wait_for_backlogs_turbo_stream_event(timeout:) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + + loop do + return if page.evaluate_script("window.__opBacklogsTurboStreamState?.rendered === true") + + if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + raise "wait_for_backlogs_turbo_stream: no turbo stream rendered\n#{backlogs_dnd_diagnostics}" + end + + sleep 0.05 + end + end + + def stop_backlogs_turbo_stream_probe + page.execute_script("window.__opBacklogsTurboStreamAbort?.abort();") + end + + def install_backlogs_dnd_probe(source:, target:, edge:) + page.execute_script(<<~JS, source, target, edge&.to_s) + window.__opBacklogsDndProbeAbort?.abort(); + + const controller = new AbortController(); + const sourceElement = arguments[0]; + const targetElement = arguments[1]; + const state = { + source: describeElement(sourceElement), + target: describeElement(targetElement), + requestedEdge: arguments[2], + events: [], + handleDropCalls: [], + snapshots: [] + }; + + function itemIdFor(element) { + const closestItem = element?.closest?.('[data-sortable-lists--item-id-value]'); + const descendantItem = element?.querySelector?.('[data-sortable-lists--item-id-value]'); + + return (closestItem ?? descendantItem) + ?.getAttribute('data-sortable-lists--item-id-value') ?? null; + } + + function backlogsItemFor(element) { + return element?.closest?.('[data-sortable-lists--item-id-value]') ?? + element?.querySelector?.('[data-sortable-lists--item-id-value]') ?? + null; + } + + function controllerInfo(element) { + const item = backlogsItemFor(element); + const application = window.Stimulus; + + if (!item || !application?.getControllerForElementAndIdentifier) { + return { available: false }; + } + + const controller = application.getControllerForElementAndIdentifier(item, 'sortable-lists--item'); + + return { + available: true, + connected: Boolean(controller), + idValue: controller?.idValue ?? null, + hasCleanupFn: Boolean(controller?.cleanupFn) + }; + } + + function dataSummary(data) { + if (!data || typeof data !== 'object') { + return data ?? null; + } + + const entries = Object.fromEntries(Object.entries(data)); + const symbols = Object.getOwnPropertySymbols(data).map((symbol) => ({ + description: symbol.description, + value: data[symbol] + })); + + return { entries, symbols }; + } + + function dropTargetSummary(dropTarget) { + return { + data: dataSummary(dropTarget.data), + element: describeElement(dropTarget.element) + }; + } + + function patchSortableListsController() { + const application = window.Stimulus; + const root = sourceElement.closest('[data-controller~="sortable-lists"]'); + const sortableListsController = root && application?.getControllerForElementAndIdentifier + ? application.getControllerForElementAndIdentifier(root, 'sortable-lists') + : null; + + state.sortableListsController = { + rootFound: Boolean(root), + connected: Boolean(sortableListsController), + patched: false + }; + + if (!sortableListsController?.handleDrop || sortableListsController.__opBacklogsDndProbePatched) { + return; + } + + const originalHandleDrop = sortableListsController.handleDrop.bind(sortableListsController); + + sortableListsController.handleDrop = (payload) => { + state.handleDropCalls.push({ + source: { + data: dataSummary(payload.source?.data), + element: describeElement(payload.source?.element) + }, + dropTargets: payload.location?.current?.dropTargets?.map(dropTargetSummary) ?? [], + input: payload.location?.current?.input ?? null, + time: Math.round(performance.now()) + }); + + return originalHandleDrop(payload); + }; + + sortableListsController.__opBacklogsDndProbePatched = true; + state.sortableListsController.patched = true; + } + + function describeElement(element) { + if (!element) { + return { found: false }; + } + + const rect = element.getBoundingClientRect(); + const item = backlogsItemFor(element); + const row = element.closest?.('.Box-row'); + + return { + found: true, + tagName: element.tagName, + itemId: itemIdFor(element), + testSelector: element.closest?.('[data-test-selector]')?.getAttribute('data-test-selector') ?? null, + itemTagName: item?.tagName ?? null, + draggable: item?.draggable ?? element.draggable, + draggableAttribute: item?.getAttribute('draggable') ?? element.getAttribute('draggable'), + dataDropTargetForElement: item?.getAttribute('data-drop-target-for-element') ?? + element.getAttribute('data-drop-target-for-element'), + controller: controllerInfo(element), + rowClassName: row?.className ?? null, + rect: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height) + } + }; + } + + function snapshot(label) { + state.snapshots.push({ + label, + draggingCount: document.querySelectorAll('[data-dragging]').length, + honeyPotCount: document.querySelectorAll('[data-pdnd-honey-pot]').length, + dropTargets: document.querySelectorAll('[data-drop-target-for-element]').length, + dropPositions: Array + .from(document.querySelectorAll('[data-drop-position]')) + .map((element) => ({ + itemId: itemIdFor(element), + position: element.getAttribute('data-drop-position') + })), + source: describeElement(sourceElement), + target: describeElement(targetElement), + time: Math.round(performance.now()) + }); + } + + function pushEvent(event) { + const elementsFromPoint = event.clientX == null || event.clientY == null + ? [] + : Array + .from(document.elementsFromPoint(event.clientX, event.clientY)) + .slice(0, 6) + .map(describeElement); + + state.events.push({ + type: event.type, + targetItemId: itemIdFor(event.target), + clientX: event.clientX, + clientY: event.clientY, + defaultPrevented: event.defaultPrevented, + dropEffect: event.dataTransfer?.dropEffect ?? null, + effectAllowed: event.dataTransfer?.effectAllowed ?? null, + draggingCount: document.querySelectorAll('[data-dragging]').length, + honeyPotCount: document.querySelectorAll('[data-pdnd-honey-pot]').length, + dropPositions: Array + .from(document.querySelectorAll('[data-drop-position]')) + .map((element) => ({ + itemId: itemIdFor(element), + position: element.getAttribute('data-drop-position') + })), + elementsFromPoint, + time: Math.round(performance.now()) + }); + + if (state.events.length > 100) { + state.events.shift(); + } + } + + ['mousedown', 'mousemove', 'mouseup', 'dragstart', 'dragenter', 'dragover', 'dragleave', 'drop', 'dragend'] + .forEach((type) => document.addEventListener(type, pushEvent, { + capture: true, + signal: controller.signal + })); + + patchSortableListsController(); + snapshot('before-drag'); + + window.__opBacklogsDndProbeAbort = controller; + window.__opBacklogsDndProbeState = state; + JS + end + + def install_backlogs_move_request_probe + page.execute_script(<<~JS) + window.__opBacklogsMoveRequestProbe = { requests: [] }; + + if (!window.__opBacklogsOriginalFetch) { + window.__opBacklogsOriginalFetch = window.fetch; + } + + window.fetch = (...args) => { + const request = args[0]; + const options = args[1] ?? {}; + const url = String(request?.url ?? request); + const method = String(request?.method ?? options.method ?? 'GET').toUpperCase(); + + if (method === 'PUT' && url.includes('/backlogs/')) { + window.__opBacklogsMoveRequestProbe.requests.push({ + url, + method, + time: Math.round(performance.now()) + }); + } + + return window.__opBacklogsOriginalFetch(...args); + }; + JS + end + + def stop_backlogs_move_request_probe + page.execute_script(<<~JS) + if (window.__opBacklogsOriginalFetch) { + window.fetch = window.__opBacklogsOriginalFetch; + } + JS + end + + def backlogs_dnd_diagnostics + diagnostics = page.evaluate_script(<<~JS) + (() => { + const dnd = window.__opBacklogsDndProbeState ?? null; + const turbo = window.__opBacklogsTurboStreamState ?? null; + + if (dnd) { + dnd.snapshots.push({ + label: 'on-timeout', + draggingCount: document.querySelectorAll('[data-dragging]').length, + honeyPotCount: document.querySelectorAll('[data-pdnd-honey-pot]').length, + dropTargets: document.querySelectorAll('[data-drop-target-for-element]').length, + dropPositions: Array + .from(document.querySelectorAll('[data-drop-position]')) + .map((element) => ({ + itemId: element + .closest('[data-sortable-lists--item-id-value]') + ?.getAttribute('data-sortable-lists--item-id-value') ?? null, + position: element.getAttribute('data-drop-position') + })), + source: dnd.source, + target: dnd.target, + time: Math.round(performance.now()) + }); + } + + return { dnd, turbo }; + })() + JS + + JSON.pretty_generate(diagnostics) + end + + def clear_pragmatic_dnd_honey_pot + page.execute_script(<<~JS) + document + .querySelectorAll('[data-pdnd-honey-pot]') + .forEach((element) => element.remove()); + JS end def sprint_complete_modal_selector diff --git a/spec/components/open_project/common/border_box_list_component_spec.rb b/spec/components/open_project/common/border_box_list_component_spec.rb index fea303c96548..187b434ae409 100644 --- a/spec/components/open_project/common/border_box_list_component_spec.rb +++ b/spec/components/open_project/common/border_box_list_component_spec.rb @@ -528,6 +528,10 @@ def call ) end + it "applies the Box-card class to the work-package card" do + expect(rendered_component).to have_css(".op-work-package-card.Box-card") + end + it "sets the test selector" do item = described_class::WorkPackageItem.new( work_package:, diff --git a/spec/components/open_project/common/work_package_card_component_spec.rb b/spec/components/open_project/common/work_package_card_component_spec.rb index 6701d91b387b..f9ebc833a94d 100644 --- a/spec/components/open_project/common/work_package_card_component_spec.rb +++ b/spec/components/open_project/common/work_package_card_component_spec.rb @@ -176,5 +176,19 @@ end end end + + it "passes system arguments to the root card element" do + rendered = render_inline( + described_class.new( + work_package:, + data: { controller: "custom-card" }, + draggable: true + ) + ) + + expect(rendered).to have_css( + ".op-work-package-card[data-controller='custom-card'][draggable='true']" + ) + end end end From aa5262fd16c71595d5b4011d9c663d49f6da328b Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 8 Jun 2026 22:45:14 +0100 Subject: [PATCH 004/105] Switch to gray row hover color --- .../common/border_box_list_component/work_package_item.rb | 2 +- .../backlogs/work_package_card_list_item_component_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/open_project/common/border_box_list_component/work_package_item.rb b/app/components/open_project/common/border_box_list_component/work_package_item.rb index 7ea8188307b6..29c443ec04c5 100644 --- a/app/components/open_project/common/border_box_list_component/work_package_item.rb +++ b/app/components/open_project/common/border_box_list_component/work_package_item.rb @@ -115,7 +115,7 @@ def build_card def row_classes class_names( - "Box-row--hover-blue", + "Box-row--hover-gray", "Box-row--focus-gray", "Box-row--clickable", "Box-row--draggable" => draggable? diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb index d417f729da99..0c39e6ee59ee 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb @@ -66,7 +66,7 @@ describe "#row_args" do it "marks the row as clickable and wires it as the Backlogs draggable item" do expect(item.row_args[:classes]).to include( - "Box-row--hover-blue", + "Box-row--hover-gray", "Box-row--focus-gray", "Box-row--clickable" ) From 64cae4a197252fa6a9f4bb31032e68c0bc35fded Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 8 Jun 2026 23:01:00 +0100 Subject: [PATCH 005/105] Strip include-fragment nodes from previews Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../controllers/dynamic/sortable-lists/item.controller.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index 5bde75e45a62..65153d37bc64 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -207,6 +207,9 @@ export default class ItemController extends Controller { } private sanitizePreview(element:HTMLElement) { + // Avoid side effects from custom elements (e.g. Primer include-fragment) in the cloned preview. + element.querySelectorAll('include-fragment').forEach((fragment) => fragment.remove()); + const nodes = [element, ...Array.from(element.querySelectorAll('*'))]; for (const node of nodes) { From 2a6f4c4519a11c67a66b6bb4be585e204bf72d31 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 9 Jun 2026 00:10:09 +0100 Subject: [PATCH 006/105] Extract Backlogs::MoveTarget value object Centralises the sprint/backlog_bucket/inbox to column mapping that was duplicated as inverse logic between `WorkPackageCardMenuComponent` (work package state to list inputs) and `Backlogs::WorkPackages::UpdateService` (list inputs to persisted attributes), so the list-type vocabulary and the `list_id` normalisation rules live in a single place. --- .../work_package_card_menu_component.rb | 12 +- .../app/models/backlogs/move_target.rb | 89 ++++++++++++++ .../backlogs/work_packages/update_service.rb | 13 +- .../spec/models/backlogs/move_target_spec.rb | 115 ++++++++++++++++++ 4 files changed, 211 insertions(+), 18 deletions(-) create mode 100644 modules/backlogs/app/models/backlogs/move_target.rb create mode 100644 modules/backlogs/spec/models/backlogs/move_target_spec.rb diff --git a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb index 4fa61a08dc00..ca295d5c281a 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb @@ -116,15 +116,9 @@ def last_item? end def current_list_inputs - list_type, list_id = if work_package.backlog_bucket_id? - ["backlog_bucket", work_package.backlog_bucket_id] - elsif work_package.sprint_id? - ["sprint", work_package.sprint_id] - else - ["inbox", nil] - end - - [{ name: "list_type", value: list_type }, { name: "list_id", value: list_id }] + target = Backlogs::MoveTarget.for(work_package) + + [{ name: "list_type", value: target.list_type }, { name: "list_id", value: target.list_id }] end end end diff --git a/modules/backlogs/app/models/backlogs/move_target.rb b/modules/backlogs/app/models/backlogs/move_target.rb new file mode 100644 index 000000000000..cd34b23dc6cd --- /dev/null +++ b/modules/backlogs/app/models/backlogs/move_target.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Backlogs + # Value object describing where a story currently lives (or should move to): + # a sprint, a backlog bucket, or the inbox. It is the single place that maps + # between the +(list_type, list_id)+ wire format used by the move UI and the + # +(sprint_id, backlog_bucket_id)+ columns persisted on the work package. + class MoveTarget + attr_reader :sprint_id, :backlog_bucket_id + + def initialize(sprint_id: nil, backlog_bucket_id: nil) + @sprint_id = sprint_id + @backlog_bucket_id = backlog_bucket_id + end + + # Encode the current location of a work package. + def self.for(work_package) + if work_package.backlog_bucket_id + new(backlog_bucket_id: work_package.backlog_bucket_id) + else + new(sprint_id: work_package.sprint_id) + end + end + + # Decode a +(list_type, list_id)+ pair from the move UI. Returns +nil+ when + # the combination is invalid (unknown type, missing/non-numeric id for a + # sprint or bucket, or an id supplied for the inbox). + def self.from_list(list_type, list_id) + case [list_type, list_id.presence&.to_s] + in ["sprint", /\A\d+\z/ => sprint_id] + new(sprint_id:) + in ["backlog_bucket", /\A\d+\z/ => backlog_bucket_id] + new(backlog_bucket_id:) + in ["inbox", nil] + new + else + nil + end + end + + def list_type + if backlog_bucket_id + "backlog_bucket" + elsif sprint_id + "sprint" + else + "inbox" + end + end + + def list_id + backlog_bucket_id || sprint_id + end + + # Attributes to assign to the work package, in the shape the inner + # WorkPackages::UpdateService expects. + def attributes + { backlog_bucket_id:, sprint_id: } + end + end +end diff --git a/modules/backlogs/app/services/backlogs/work_packages/update_service.rb b/modules/backlogs/app/services/backlogs/work_packages/update_service.rb index 5885ccddf4fb..d248405ccb12 100644 --- a/modules/backlogs/app/services/backlogs/work_packages/update_service.rb +++ b/modules/backlogs/app/services/backlogs/work_packages/update_service.rb @@ -51,8 +51,6 @@ def call(direction: nil, list_type: nil, list_id: nil, position: nil, prev_id: n private def resolve_required_attributes(direction:, list_type:, list_id:) - list_id = list_id.presence&.to_s - if list_type.present? && direction ServiceResult.failure(message: I18n.t("backlogs.stories.update_service.ambiguous_target")) elsif list_type.present? @@ -65,13 +63,10 @@ def resolve_required_attributes(direction:, list_type:, list_id:) end def attributes_result_from_list(list_type, list_id) - case [list_type, list_id] - in ["sprint", /\A\d+\z/ => sprint_id] - ServiceResult.success(result: { backlog_bucket_id: nil, sprint_id: }) - in ["backlog_bucket", /\A\d+\z/ => backlog_bucket_id] - ServiceResult.success(result: { backlog_bucket_id:, sprint_id: nil }) - in ["inbox", nil] - ServiceResult.success(result: { backlog_bucket_id: nil, sprint_id: nil }) + target = Backlogs::MoveTarget.from_list(list_type, list_id) + + if target + ServiceResult.success(result: target.attributes) else ServiceResult.failure(message: I18n.t("backlogs.stories.update_service.invalid_target_type")) end diff --git a/modules/backlogs/spec/models/backlogs/move_target_spec.rb b/modules/backlogs/spec/models/backlogs/move_target_spec.rb new file mode 100644 index 000000000000..c55384451898 --- /dev/null +++ b/modules/backlogs/spec/models/backlogs/move_target_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Backlogs::MoveTarget do + describe ".for" do + it "encodes a sprint story" do + work_package = instance_double(WorkPackage, sprint_id: 42, backlog_bucket_id: nil) + target = described_class.for(work_package) + + expect(target.list_type).to eq("sprint") + expect(target.list_id).to eq(42) + end + + it "encodes a backlog bucket story" do + work_package = instance_double(WorkPackage, sprint_id: nil, backlog_bucket_id: 99) + target = described_class.for(work_package) + + expect(target.list_type).to eq("backlog_bucket") + expect(target.list_id).to eq(99) + end + + it "encodes an inbox story" do + work_package = instance_double(WorkPackage, sprint_id: nil, backlog_bucket_id: nil) + target = described_class.for(work_package) + + expect(target.list_type).to eq("inbox") + expect(target.list_id).to be_nil + end + + it "treats a bucket assignment as a bucket even when a sprint is also set" do + work_package = instance_double(WorkPackage, sprint_id: 42, backlog_bucket_id: 99) + target = described_class.for(work_package) + + expect(target.list_type).to eq("backlog_bucket") + expect(target.list_id).to eq(99) + expect(target.attributes).to eq(backlog_bucket_id: 99, sprint_id: nil) + end + end + + describe ".from_list" do + it "decodes a sprint with a string id" do + expect(described_class.from_list("sprint", "42").attributes) + .to eq(backlog_bucket_id: nil, sprint_id: "42") + end + + it "normalizes an integer sprint id before validating" do + expect(described_class.from_list("sprint", 42).attributes) + .to eq(backlog_bucket_id: nil, sprint_id: "42") + end + + it "decodes a backlog bucket" do + expect(described_class.from_list("backlog_bucket", "99").attributes) + .to eq(backlog_bucket_id: "99", sprint_id: nil) + end + + it "decodes the inbox with no id" do + expect(described_class.from_list("inbox", nil).attributes) + .to eq(backlog_bucket_id: nil, sprint_id: nil) + end + + it "decodes the inbox with a blank id" do + expect(described_class.from_list("inbox", "").attributes) + .to eq(backlog_bucket_id: nil, sprint_id: nil) + end + + it "rejects an unknown list type" do + expect(described_class.from_list("unknown", "42")).to be_nil + end + + it "rejects a sprint without an id" do + expect(described_class.from_list("sprint", nil)).to be_nil + end + + it "rejects a sprint with a blank id" do + expect(described_class.from_list("sprint", "")).to be_nil + end + + it "rejects a sprint with a non-numeric id" do + expect(described_class.from_list("sprint", "unknown")).to be_nil + end + + it "rejects the inbox with an id" do + expect(described_class.from_list("inbox", "1")).to be_nil + end + end +end From 38bb518ff954091c6dd0dff0f0426f1b6a721a59 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 9 Jun 2026 19:35:30 +0100 Subject: [PATCH 007/105] Use Backlogs move URL template for drag and drop Replaces the per-item move URLs with a single RFC 6570 URL template that the `sortable-lists` container renders once, and the Stimulus controller expands client-side. This deduplicates the URL across the rows and keeps query parameters such as `all=1` in one place. --- .../work_package_card_list_item_component.rb | 7 +--- .../app/helpers/backlogs/common_helper.rb | 7 ++++ .../backlogs/backlog/_backlog_list.html.erb | 3 +- .../backlogs/bucket_component_spec.rb | 8 ---- .../backlogs/sprint_component_spec.rb | 15 ------- ...k_package_card_list_item_component_spec.rb | 39 ++++--------------- .../spec/requests/backlogs/backlog_spec.rb | 14 +++++++ 7 files changed, 31 insertions(+), 62 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb index b67d264b0640..d17fb9b0edad 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb @@ -54,10 +54,6 @@ def full_url url_helpers.work_package_path(work_package) end - def move_url - url_helpers.move_project_backlogs_work_package_path(project, work_package, params) - end - def menu_src url_helpers.menu_project_backlogs_work_package_path(project, work_package, params) end @@ -90,8 +86,7 @@ def draggable_data { controller: "sortable-lists--item", sortable_lists__item_id_value: work_package.id, - sortable_lists__item_type_value: "work_package", - sortable_lists__item_move_url_value: move_url + sortable_lists__item_type_value: "work_package" } end diff --git a/modules/backlogs/app/helpers/backlogs/common_helper.rb b/modules/backlogs/app/helpers/backlogs/common_helper.rb index 4fceff480dbf..f6b700b815cc 100644 --- a/modules/backlogs/app/helpers/backlogs/common_helper.rb +++ b/modules/backlogs/app/helpers/backlogs/common_helper.rb @@ -55,5 +55,12 @@ def show_all_backlog def all_backlogs_params show_all_backlog ? { all: 1 } : {} end + + def backlogs_move_url_template(project) + id_placeholder = "__work_package_id__" + + move_project_backlogs_work_package_path(project, id_placeholder, all_backlogs_params) + .sub(id_placeholder, "{id}") + end end end diff --git a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb index 0e384062a930..df6827506de0 100644 --- a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb @@ -33,7 +33,8 @@ See COPYRIGHT and LICENSE files for more details. data: { controller: "backlogs--list-refresh" } do %>
      + data-sortable-lists-accepted-type-value="work_package" + data-sortable-lists-move-url-template-value="<%= backlogs_move_url_template(@project) %>">
      <%= render Backlogs::BacklogComponent.new( diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index 3b331d52b64a..8177e80592f1 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -138,8 +138,6 @@ def render_component expect(row["data-controller"]).to eq("sortable-lists--item") expect(row["data-sortable-lists--item-id-value"]).to eq(work_package.id.to_s) expect(row["data-sortable-lists--item-type-value"]).to eq("work_package") - expect(row["data-sortable-lists--item-move-url-value"]) - .to end_with(move_project_backlogs_work_package_path(project, work_package)) expect(row["draggable"]).to eq("true") end @@ -183,12 +181,6 @@ def render_component end end - it "includes all=1 in the drop URL" do - expect(rendered_component).to have_css(".Box-row#work_package_#{work_package.id}") do |row| - expect(row["data-sortable-lists--item-move-url-value"]).to include("all=1") - end - end - it "includes all=1 in the action-menu src" do expect(rendered_component).to have_element( "include-fragment", diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index 289251e0532a..ff2dbd47e7b9 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -118,8 +118,6 @@ def menu_items expect(row["data-controller"]).to eq("sortable-lists--item") expect(row["data-sortable-lists--item-id-value"]).to eq(work_package1.id.to_s) expect(row["data-sortable-lists--item-type-value"]).to eq("work_package") - expect(row["data-sortable-lists--item-move-url-value"]) - .to end_with(move_project_backlogs_work_package_path(project, work_package1)) expect(row["draggable"]).to eq("true") end @@ -130,19 +128,6 @@ def menu_items end end - context "when params[:all] is true" do - before do - vc_test_controller.params[:all] = "1" - end - - it "propagates ?all=1 to the work package drop URL" do - expect(rendered_component).to have_css(".Box-row#work_package_#{work_package1.id}") do |row| - expect(row["data-sortable-lists--item-move-url-value"]) - .to eq(move_project_backlogs_work_package_path(project, work_package1, all: "1")) - end - end - end - it "renders the sprint kebab menu in the header" do expect(rendered_component).to have_element :"action-menu" end diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb index 0c39e6ee59ee..8caa0f9cb6f7 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb @@ -74,9 +74,7 @@ expect(item.row_args[:data]).to include( controller: "sortable-lists--item", sortable_lists__item_id_value: work_package.id, - sortable_lists__item_type_value: "work_package", - sortable_lists__item_move_url_value: - a_string_ending_with(move_project_backlogs_work_package_path(project, work_package)) + sortable_lists__item_type_value: "work_package" ) expect(item.row_args[:draggable]).to be(true) expect(item.row_args).not_to include(:tabindex) @@ -108,35 +106,13 @@ end end - describe "URL derivation by container" do - context "with a sprint container" do - it "uses the shared move route" do - render_inline(item.card) - card = page.find(".op-work-package-card") - - expect(card["data-backlogs--story-split-url-value"]) - .to end_with(project_backlogs_backlog_details_path(project, work_package)) - expect(item.row_args[:data][:sortable_lists__item_move_url_value]) - .to end_with(move_project_backlogs_work_package_path(project, work_package)) - end - end - - context "with a backlog bucket container" do - let(:container) { backlog_bucket } - - it "uses the shared move route" do - expect(item.row_args[:data][:sortable_lists__item_move_url_value]) - .to end_with(move_project_backlogs_work_package_path(project, work_package)) - end - end + describe "URL derivation" do + it "uses the shared split-view route" do + render_inline(item.card) + card = page.find(".op-work-package-card") - context "with an inbox container id" do - let(:container) { "inbox_project_#{project.id}" } - - it "uses the shared move route" do - expect(item.row_args[:data][:sortable_lists__item_move_url_value]) - .to end_with(move_project_backlogs_work_package_path(project, work_package)) - end + expect(card["data-backlogs--story-split-url-value"]) + .to end_with(project_backlogs_backlog_details_path(project, work_package)) end context "with params" do @@ -147,7 +123,6 @@ card = page.find(".op-work-package-card") expect(card["data-backlogs--story-split-url-value"]).to match(/all=1/) - expect(item.row_args[:data][:sortable_lists__item_move_url_value]).to match(/all=1/) end end end diff --git a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb index 691defb4f6b1..2ac7d9822fe5 100644 --- a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb +++ b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb @@ -89,12 +89,26 @@ expect(response.body).to include('class="op-sprint-planning-container"') expect(response.body).to include('data-controller="sortable-lists"') expect(response.body).to include('data-sortable-lists-accepted-type-value="work_package"') + expect(response.body).to include( + %(data-sortable-lists-move-url-template-value="/projects/#{project.identifier}/backlogs/work_packages/{id}/move") + ) expect(response.body).to include('id="owner_backlogs_container"') expect(response.body).to include('id="sprint_backlogs_container"') expect(response.body.scan('data-sortable-lists-target="scrollable"').size).to eq(2) expect(response).to have_no_turbo_frame "content-bodyRight" end + it "passes all=1 to the move URL template when requested" do + get "/projects/#{project.identifier}/backlogs/backlog", + params: { all: "1" }, + headers: { "Turbo-Frame" => "backlogs_container" } + + expect(response).to have_http_status(:ok) + expect(response.body).to include( + %(data-sortable-lists-move-url-template-value="/projects/#{project.identifier}/backlogs/work_packages/{id}/move?all=1") + ) + end + context "with no sprints available" do before do allow(Sprint) From eb09fc543f3c793090f778c268b867d315af82ca Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 9 Jun 2026 19:07:12 +0100 Subject: [PATCH 008/105] [AGILE-284] Reorder sortable lists optimistically Moves the dragged row to its target position in the DOM as soon as the drop lands and persists the move in the background, so that list reordering no longer blocks on the server round-trip. A rejected move restores the row to its original slot and eases it back with a FLIP animation, while the forward drop needs none since Pragmatic DnD already leaves the row where the user released it. https://community.openproject.org/wp/AGILE-284 --- .../dynamic/sortable-lists.controller.spec.ts | 48 +++++++ .../dynamic/sortable-lists.controller.ts | 43 ++++-- .../sortable-lists/drag-and-drop.spec.ts | 129 ++++++++++++++++++ .../dynamic/sortable-lists/drag-and-drop.ts | 84 +++++++++++- .../src/stimulus/helpers/flip-helper.spec.ts | 95 +++++++++++++ frontend/src/stimulus/helpers/flip-helper.ts | 53 +++++++ 6 files changed, 437 insertions(+), 15 deletions(-) create mode 100644 frontend/src/stimulus/helpers/flip-helper.spec.ts create mode 100644 frontend/src/stimulus/helpers/flip-helper.ts diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index bfe8fce375d6..9afb3b093498 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -209,6 +209,11 @@ describe('Sortable lists controller', () => { return vi.mocked(dropTargetForElements).mock.calls.find(([options]) => options.element === element)?.[0]; } + function itemIds(list:HTMLElement):string[] { + return Array.from(list.querySelectorAll('[data-sortable-lists--item-id-value]')) + .map((element) => element.getAttribute('data-sortable-lists--item-id-value')!); + } + beforeEach(async () => { vi.clearAllMocks(); @@ -460,6 +465,49 @@ describe('Sortable lists controller', () => { window.removeEventListener('op:toasters:add', onToast); }); + it('moves the dropped row into the target list before the move request resolves', async () => { + let resolveMove:(response:{ ok:boolean }) => void; + + vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { + return { + perform: vi.fn(() => new Promise<{ ok:boolean }>((resolve) => { + resolveMove = resolve; + })), + }; + }); + + const { sourceList, targetList, firstSourceItem } = renderFixture(); + + await ctx.nextFrame(); + await dropCurrentItemOnList(firstSourceItem, targetList); + + // The row is appended optimistically while the request is still pending. + expect(itemIds(targetList)).toEqual(['4', '5', '1']); + expect(itemIds(sourceList)).toEqual(['2', '3']); + + resolveMove!({ ok: true }); + await flushPromises(); + + expect(itemIds(targetList)).toEqual(['4', '5', '1']); + }); + + it('rolls the dropped row back to its original position when the move request fails', async () => { + vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { + return { + perform: vi.fn(() => Promise.reject(new Error('Network failure'))), + }; + }); + + const { sourceList, targetList, firstSourceItem } = renderFixture(); + + await ctx.nextFrame(); + await dropCurrentItemOnList(firstSourceItem, targetList); + await flushPromises(); + + expect(itemIds(sourceList)).toEqual(['1', '2', '3']); + expect(itemIds(targetList)).toEqual(['4', '5']); + }); + it('registers Backlogs lists as drop targets', async () => { const { sourceList, targetList } = renderFixture(); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index d613adec548e..f58bd15bdb82 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -36,21 +36,25 @@ import { import { Controller } from '@hotwired/stimulus'; import { FetchRequest } from '@rails/request.js'; import { debugLog } from 'core-app/shared/helpers/debug_output'; +import { flipMove } from 'core-stimulus/helpers/flip-helper'; import { withLoadingIndicator } from 'core-stimulus/helpers/request-helpers'; import URI from 'urijs'; import 'urijs/src/URITemplate'; import { acceptsSortableItemType, buildMoveFormData, + captureRowPositions, isSortableItemData, isSourceListTarget, + reorderRows, resolveFallbackDropTarget, + restoreRowPositions, resolveListAppendPreviousItemId, resolveListData, resolvePreviousSortableItemId, sortableItemSelector, + sortableListSelector, sortableListsMovingAttribute, - type SortableItemData, type SortableListData, } from './sortable-lists/drag-and-drop'; @@ -239,11 +243,29 @@ export default class SortableListsController extends Controller { list: targetElement, }); - await this.moveItem({ + const sourceRow = source.element.closest('li'); + const listElement = targetElement.closest(sortableListSelector); + if (!(sourceRow instanceof HTMLElement) || !(listElement instanceof HTMLElement)) { + return; + } + + // Move the row optimistically, then persist. The server response (a + // turbo-stream) reconciles the list on success; a failure rolls the row + // back to where it started. + const rows = [sourceRow]; + const rollback = captureRowPositions(rows); + reorderRows({ rows, list: listElement, previousItemId }); + + const moved = await this.moveItem({ listData, previousItemId, - sourceData: source.data, + moveUrl, }); + + if (!moved) { + flipMove(rows, () => restoreRowPositions(rollback)); + this.dispatchErrorToast(); + } } private resolveMoveUrl(data:{ itemId:string; moveUrl?:string }):string|null { @@ -261,17 +283,12 @@ export default class SortableListsController extends Controller { private async moveItem({ listData, previousItemId, - sourceData, + moveUrl, }:{ listData:SortableListData; previousItemId:string|null; - sourceData:SortableItemData; - }):Promise { - const moveUrl = this.resolveMoveUrl(sourceData); - if (!moveUrl) { - return; - } - + moveUrl:string; + }):Promise { const request = new FetchRequest( 'put', moveUrl, @@ -292,9 +309,11 @@ export default class SortableListsController extends Controller { if (!response.ok) { debugLog(`Failed to move sortable list item: ${response.statusCode}`); } + + return response.ok; } catch (error) { debugLog('Failed to move sortable list item due to request error', error); - this.dispatchErrorToast(); + return false; } finally { this.setMoving(false); } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts index a792dc0398ed..de9dc415c5ef 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -26,17 +26,22 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { vi } from 'vitest'; + import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { acceptsSortableItemType, buildMoveFormData, + captureRowPositions, isSortableItemData, isSortableListData, + reorderRows, resolveFallbackDropTarget, resolveItemType, resolveListAppendPreviousItemId, resolveListData, resolvePreviousSortableItemId, + restoreRowPositions, sortableItemData, sortableListData, } from './drag-and-drop'; @@ -378,6 +383,28 @@ describe('sortable lists drag and drop helpers', () => { expect(target?.data.listId).toEqual('7'); }); + it('resolves a list when the pointer lands on list whitespace above its items', () => { + const root = document.createElement('div'); + const list = document.createElement('div'); + + list.setAttribute('data-sortable-lists-target', 'list'); + list.setAttribute('data-sortable-lists-list-type', 'backlog_bucket'); + list.setAttribute('data-sortable-lists-list-id', '7'); + list.appendChild(itemRow('42')); + root.appendChild(list); + document.body.appendChild(root); + stubElementFromPoint(list); + + const target = resolveFallbackDropTarget({ + input: input(), + root, + }); + + expect(target?.element).toBe(list); + expect(target?.isItem).toBe(false); + expect(isSortableListData(target!.data)).toBe(true); + }); + it('resolves the containing list instead of the dragged source item', () => { const root = document.createElement('div'); const list = document.createElement('div'); @@ -436,4 +463,106 @@ describe('sortable lists drag and drop helpers', () => { expect(resolveListAppendPreviousItemId({ sourceItemId: '1', list })).toBeNull(); }); }); + + function listElement():HTMLUListElement { + const list = document.createElement('ul'); + + list.setAttribute('data-sortable-lists-target', 'list'); + + return list; + } + + function itemIdOrder(list:HTMLElement):string[] { + return Array.from(list.querySelectorAll('[data-sortable-lists--item-id-value]')) + .map((element) => element.getAttribute('data-sortable-lists--item-id-value')!); + } + + describe('reorderRows', () => { + it('moves a row to sit immediately after the previous item anchor', () => { + const list = listElement(); + const [one, two, three] = ['1', '2', '3'].map(itemRow); + + list.append(one, two, three); + reorderRows({ rows: [one], list, previousItemId: '2' }); + + expect(itemIdOrder(list)).toEqual(['2', '1', '3']); + }); + + it('moves a row to the top of the list before the first existing row', () => { + const list = listElement(); + const [one, two, three] = ['1', '2', '3'].map(itemRow); + + list.append(one, two, three); + reorderRows({ rows: [three], list, previousItemId: null }); + + expect(itemIdOrder(list)).toEqual(['3', '1', '2']); + }); + + it('keeps a top-of-list move inside a nested list element instead of escaping it', () => { + const list = document.createElement('div'); + const inner = document.createElement('ul'); + const [one, two, three] = ['1', '2', '3'].map(itemRow); + + list.setAttribute('data-sortable-lists-target', 'list'); + inner.append(one, two, three); + list.append(inner); + + reorderRows({ rows: [three], list, previousItemId: null }); + + expect(three.parentElement).toBe(inner); + expect(itemIdOrder(list)).toEqual(['3', '1', '2']); + }); + + it('inserts a moved group after the anchor preserving their order', () => { + const list = listElement(); + const [one, two, three, four] = ['1', '2', '3', '4'].map(itemRow); + + list.append(one, two, three, four); + reorderRows({ rows: [three, four], list, previousItemId: '1' }); + + expect(itemIdOrder(list)).toEqual(['1', '3', '4', '2']); + }); + + it('anchors on a truncation marker row when the previous item is hidden', () => { + const list = listElement(); + const [one, two, three] = ['1', '2', '3'].map(itemRow); + const marker = showMoreRow('hidden'); + + list.append(three, one, marker, two); + reorderRows({ rows: [three], list, previousItemId: 'hidden' }); + + expect(three.previousElementSibling).toBe(marker); + expect(itemIdOrder(list)).toEqual(['1', '3', '2']); + }); + }); + + describe('captureRowPositions / restoreRowPositions', () => { + it('restores a row to its original position after an optimistic move', () => { + const list = listElement(); + const [one, two, three] = ['1', '2', '3'].map(itemRow); + + list.append(one, two, three); + const snapshot = captureRowPositions([three]); + + reorderRows({ rows: [three], list, previousItemId: null }); + expect(itemIdOrder(list)).toEqual(['3', '1', '2']); + + restoreRowPositions(snapshot); + expect(itemIdOrder(list)).toEqual(['1', '2', '3']); + }); + + it('restores a multi-row group to its original order', () => { + const list = listElement(); + const [one, two, three, four] = ['1', '2', '3', '4'].map(itemRow); + + list.append(one, two, three, four); + const snapshot = captureRowPositions([two, three]); + + reorderRows({ rows: [two, three], list, previousItemId: '4' }); + expect(itemIdOrder(list)).toEqual(['1', '4', '2', '3']); + + restoreRowPositions(snapshot); + expect(itemIdOrder(list)).toEqual(['1', '2', '3', '4']); + }); + }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index 9f5362bb58e7..27a720691c84 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -175,12 +175,16 @@ export function isSourceListTarget({ return sourceElement.closest(sortableListSelector) === targetElement; } -function resolveItemElement(element:Element):HTMLElement|null { +function resolveClosestItemElement(element:Element):HTMLElement|null { if (element instanceof HTMLElement && element.matches(sortableItemSelector)) { return element; } - return element.closest(sortableItemSelector) ?? + return element.closest(sortableItemSelector); +} + +function resolveItemElement(element:Element):HTMLElement|null { + return resolveClosestItemElement(element) ?? element.querySelector(sortableItemSelector); } @@ -219,7 +223,7 @@ export function resolveFallbackDropTarget({ continue; } - const item = resolveItemElement(elementAtPoint); + const item = resolveClosestItemElement(elementAtPoint); if (item && item !== sourceElement && root.contains(item)) { const itemId = resolveItemId(item); @@ -286,6 +290,80 @@ export function resolvePreviousSortableItemId({ return null; } +export interface RowPlacement { + row:HTMLElement; + parent:Node|null; + nextSibling:Node|null; +} + +// Snapshot each row's current location so an optimistic move can be undone if +// the server rejects it. Captured before the move; restored in reverse so the +// stored nextSibling references are still valid when reinserting. +export function captureRowPositions(rows:HTMLElement[]):RowPlacement[] { + return rows.map((row) => ({ + row, + parent: row.parentNode, + nextSibling: row.nextSibling, + })); +} + +export function restoreRowPositions(positions:RowPlacement[]):void { + for (let i = positions.length - 1; i >= 0; i -= 1) { + const { row, parent, nextSibling } = positions[i]; + parent?.insertBefore(row, nextSibling); + } +} + +// Optimistically move rows on the client without waiting for the server. +// `rows` are the moved
    • s in order (one today, the selected set once +// multi-item DnD lands); `previousItemId` of null means top of list. +export function reorderRows({ + rows, + list, + previousItemId, +}:{ + rows:HTMLElement[]; + list:HTMLElement; + previousItemId:string|null; +}):void { + let anchor:Element|null = previousItemId ? resolveAnchorRow(list, previousItemId) : null; + + for (const row of rows) { + if (anchor) { + anchor.after(row); + } else { + insertAtListTop(list, row); + } + + anchor = row; + } +} + +// The previous item id can point at a hidden item collapsed behind a truncation +// marker row, which carries the id on data-sortable-lists-prev-item-id rather +// than exposing an item element. Anchor on that marker so the row lands next to +// the collapsed block instead of jumping to the top. +function resolveAnchorRow(list:HTMLElement, previousItemId:string):HTMLElement|null { + const escaped = CSS.escape(previousItemId); + const anchor = list.querySelector(`[data-sortable-lists--item-id-value="${escaped}"]`) + ?? list.querySelector(`[${sortablePreviousItemIdAttribute}="${escaped}"]`); + + return anchor?.closest('li') ?? null; +} + +// Rows can sit directly under the list element or inside a nested
        , so a +// plain list.prepend() would drop the row before that
          . Insert before the +// first existing row instead, keeping it in the same container as its siblings. +function insertAtListTop(list:HTMLElement, row:HTMLElement):void { + const firstRow = list.querySelector(':scope > li, :scope > ul > li'); + + if (firstRow && firstRow !== row) { + firstRow.before(row); + } else if (!firstRow) { + (list.querySelector(':scope > ul') ?? list).prepend(row); + } +} + export function resolveListAppendPreviousItemId({ sourceItemId, list, diff --git a/frontend/src/stimulus/helpers/flip-helper.spec.ts b/frontend/src/stimulus/helpers/flip-helper.spec.ts new file mode 100644 index 000000000000..5192cff7599f --- /dev/null +++ b/frontend/src/stimulus/helpers/flip-helper.spec.ts @@ -0,0 +1,95 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { vi } from 'vitest'; + +import { flipMove } from './flip-helper'; + +describe('flipMove', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function row():HTMLElement { + return document.createElement('li'); + } + + function stubReducedMotion(matches:boolean) { + vi.spyOn(window, 'matchMedia').mockReturnValue({ matches } as MediaQueryList); + } + + function stubRects(element:HTMLElement, first:{ top:number; left:number }, last:{ top:number; left:number }) { + vi.spyOn(element, 'getBoundingClientRect') + .mockReturnValueOnce({ ...first } as DOMRect) + .mockReturnValueOnce({ ...last } as DOMRect); + } + + it('runs the mutation and animates each element from its previous position', () => { + stubReducedMotion(false); + + const element = row(); + const animate = vi.spyOn(element, 'animate').mockReturnValue({} as Animation); + const mutate = vi.fn(); + + stubRects(element, { top: 100, left: 20 }, { top: 0, left: 0 }); + flipMove([element], mutate); + + expect(mutate).toHaveBeenCalledOnce(); + expect(animate).toHaveBeenCalledWith( + [{ transform: 'translate(20px, 100px)' }, { transform: 'none' }], + { duration: 200, easing: 'ease' }, + ); + }); + + it('does not animate an element that did not move', () => { + stubReducedMotion(false); + + const element = row(); + const animate = vi.spyOn(element, 'animate'); + const mutate = vi.fn(); + + stubRects(element, { top: 50, left: 0 }, { top: 50, left: 0 }); + flipMove([element], mutate); + + expect(mutate).toHaveBeenCalledOnce(); + expect(animate).not.toHaveBeenCalled(); + }); + + it('applies the mutation without animating when reduced motion is preferred', () => { + stubReducedMotion(true); + + const element = row(); + const animate = vi.spyOn(element, 'animate'); + const mutate = vi.fn(); + + flipMove([element], mutate); + + expect(mutate).toHaveBeenCalledOnce(); + expect(animate).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/stimulus/helpers/flip-helper.ts b/frontend/src/stimulus/helpers/flip-helper.ts new file mode 100644 index 000000000000..1f6850cceae7 --- /dev/null +++ b/frontend/src/stimulus/helpers/flip-helper.ts @@ -0,0 +1,53 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +// FLIP: run a DOM mutation, then slide each affected element from where it was +// to where it landed. Honours prefers-reduced-motion by applying the mutation +// without animation. +export function flipMove(elements:HTMLElement[], mutate:() => void, durationMs = 200):void { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + mutate(); + return; + } + + const firstRects = elements.map((element) => element.getBoundingClientRect()); + mutate(); + + elements.forEach((element, index) => { + const last = element.getBoundingClientRect(); + const dx = firstRects[index].left - last.left; + const dy = firstRects[index].top - last.top; + + if (dx || dy) { + element.animate( + [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], + { duration: durationMs, easing: 'ease' }, + ); + } + }); +} From ac738af8c64f98e0f1bd863637aec49a000993b2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 9 Jun 2026 20:15:48 +0100 Subject: [PATCH 009/105] Split sortable list DOM helpers from DnD module Moves the drag-and-drop-agnostic portion of the sortable lists module to `list-dom.ts`: the DOM contract's selectors, the item and row resolution and the row reordering helpers. `drag-and-drop.ts` retains the Pragmatic DnD payloads built on top, with the row-scoping selector deduplicated. --- .../dynamic/sortable-lists.controller.ts | 14 +- .../sortable-lists/drag-and-drop.spec.ts | 138 ------------- .../dynamic/sortable-lists/drag-and-drop.ts | 144 +------------ .../dynamic/sortable-lists/item.controller.ts | 8 +- .../dynamic/sortable-lists/list-dom.spec.ts | 189 ++++++++++++++++++ .../dynamic/sortable-lists/list-dom.ts | 175 ++++++++++++++++ 6 files changed, 388 insertions(+), 280 deletions(-) create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index f58bd15bdb82..0080b67d9144 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -43,20 +43,22 @@ import 'urijs/src/URITemplate'; import { acceptsSortableItemType, buildMoveFormData, - captureRowPositions, isSortableItemData, isSourceListTarget, - reorderRows, resolveFallbackDropTarget, - restoreRowPositions, - resolveListAppendPreviousItemId, resolveListData, resolvePreviousSortableItemId, + type SortableListData, +} from './sortable-lists/drag-and-drop'; +import { + captureRowPositions, + reorderRows, + resolveListAppendPreviousItemId, + restoreRowPositions, sortableItemSelector, sortableListSelector, sortableListsMovingAttribute, - type SortableListData, -} from './sortable-lists/drag-and-drop'; +} from './sortable-lists/list-dom'; type CleanupFn = () => void; type ElementDropPayload = ElementEventPayloadMap['onDrop']; diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts index de9dc415c5ef..a301f83484a7 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -32,16 +32,11 @@ import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/clo import { acceptsSortableItemType, buildMoveFormData, - captureRowPositions, isSortableItemData, isSortableListData, - reorderRows, resolveFallbackDropTarget, - resolveItemType, - resolveListAppendPreviousItemId, resolveListData, resolvePreviousSortableItemId, - restoreRowPositions, sortableItemData, sortableListData, } from './drag-and-drop'; @@ -125,20 +120,6 @@ describe('sortable lists drag and drop helpers', () => { }); }); - describe('resolveItemType', () => { - it('reads the item type Stimulus value', () => { - const item = itemRow('1'); - - item.setAttribute('data-sortable-lists--item-type-value', 'work_package'); - - expect(resolveItemType(item)).toEqual('work_package'); - }); - - it('uses a generic item type when no item type value is present', () => { - expect(resolveItemType(itemRow('1'))).toEqual('item'); - }); - }); - describe('buildMoveFormData', () => { it('serializes list data and previous item id for the move endpoint', () => { const data = buildMoveFormData({ type: 'backlog_bucket', listId: '7', previousItemId: '12' }); @@ -446,123 +427,4 @@ describe('sortable lists drag and drop helpers', () => { }); }); - describe('resolveListAppendPreviousItemId', () => { - it('returns the last item in a list while skipping the source and truncation marker rows', () => { - const list = document.createElement('ul'); - - list.append(itemRow('1'), showMoreRow(), itemRow('2'), itemRow('3')); - - expect(resolveListAppendPreviousItemId({ sourceItemId: '3', list })).toEqual('2'); - }); - - it('returns null when the list has no other items', () => { - const list = document.createElement('ul'); - - list.append(itemRow('1')); - - expect(resolveListAppendPreviousItemId({ sourceItemId: '1', list })).toBeNull(); - }); - }); - - function listElement():HTMLUListElement { - const list = document.createElement('ul'); - - list.setAttribute('data-sortable-lists-target', 'list'); - - return list; - } - - function itemIdOrder(list:HTMLElement):string[] { - return Array.from(list.querySelectorAll('[data-sortable-lists--item-id-value]')) - .map((element) => element.getAttribute('data-sortable-lists--item-id-value')!); - } - - describe('reorderRows', () => { - it('moves a row to sit immediately after the previous item anchor', () => { - const list = listElement(); - const [one, two, three] = ['1', '2', '3'].map(itemRow); - - list.append(one, two, three); - reorderRows({ rows: [one], list, previousItemId: '2' }); - - expect(itemIdOrder(list)).toEqual(['2', '1', '3']); - }); - - it('moves a row to the top of the list before the first existing row', () => { - const list = listElement(); - const [one, two, three] = ['1', '2', '3'].map(itemRow); - - list.append(one, two, three); - reorderRows({ rows: [three], list, previousItemId: null }); - - expect(itemIdOrder(list)).toEqual(['3', '1', '2']); - }); - - it('keeps a top-of-list move inside a nested list element instead of escaping it', () => { - const list = document.createElement('div'); - const inner = document.createElement('ul'); - const [one, two, three] = ['1', '2', '3'].map(itemRow); - - list.setAttribute('data-sortable-lists-target', 'list'); - inner.append(one, two, three); - list.append(inner); - - reorderRows({ rows: [three], list, previousItemId: null }); - - expect(three.parentElement).toBe(inner); - expect(itemIdOrder(list)).toEqual(['3', '1', '2']); - }); - - it('inserts a moved group after the anchor preserving their order', () => { - const list = listElement(); - const [one, two, three, four] = ['1', '2', '3', '4'].map(itemRow); - - list.append(one, two, three, four); - reorderRows({ rows: [three, four], list, previousItemId: '1' }); - - expect(itemIdOrder(list)).toEqual(['1', '3', '4', '2']); - }); - - it('anchors on a truncation marker row when the previous item is hidden', () => { - const list = listElement(); - const [one, two, three] = ['1', '2', '3'].map(itemRow); - const marker = showMoreRow('hidden'); - - list.append(three, one, marker, two); - reorderRows({ rows: [three], list, previousItemId: 'hidden' }); - - expect(three.previousElementSibling).toBe(marker); - expect(itemIdOrder(list)).toEqual(['1', '3', '2']); - }); - }); - - describe('captureRowPositions / restoreRowPositions', () => { - it('restores a row to its original position after an optimistic move', () => { - const list = listElement(); - const [one, two, three] = ['1', '2', '3'].map(itemRow); - - list.append(one, two, three); - const snapshot = captureRowPositions([three]); - - reorderRows({ rows: [three], list, previousItemId: null }); - expect(itemIdOrder(list)).toEqual(['3', '1', '2']); - - restoreRowPositions(snapshot); - expect(itemIdOrder(list)).toEqual(['1', '2', '3']); - }); - - it('restores a multi-row group to its original order', () => { - const list = listElement(); - const [one, two, three, four] = ['1', '2', '3', '4'].map(itemRow); - - list.append(one, two, three, four); - const snapshot = captureRowPositions([two, three]); - - reorderRows({ rows: [two, three], list, previousItemId: '4' }); - expect(itemIdOrder(list)).toEqual(['1', '4', '2', '3']); - - restoreRowPositions(snapshot); - expect(itemIdOrder(list)).toEqual(['1', '2', '3', '4']); - }); - }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index 27a720691c84..9391cd67a718 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -31,20 +31,20 @@ import { type Edge, } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { type Input } from '@atlaskit/pragmatic-drag-and-drop/types'; - +import { + resolveClosestItemElement, + resolveItemElement, + resolveItemId, + resolveItemType, + resolvePreviousItemId, + sortableListSelector, +} from './list-dom'; + +// The Pragmatic DnD payloads exchanged between the sortable-lists root and +// item controllers, built on top of the DOM contract in list-dom.ts. const sortableItemDataKey = Symbol('sortable-list-item'); const sortableListDataKey = Symbol('sortable-list'); -// Sortable lists use a DOM contract shared by the root and item controllers: -// the root has data-controller~="sortable-lists"; lists are root targets with -// data-sortable-lists-list-* metadata; items expose sortable-lists--item values; -// sparse non-item rows may expose data-sortable-lists-prev-item-id. -export const sortableListsMovingAttribute = 'data-sortable-lists-moving'; -export const sortableListsRootSelector = '[data-controller~="sortable-lists"]'; -export const sortableItemSelector = '[data-sortable-lists--item-id-value]'; -export const sortableListSelector = '[data-sortable-lists-target~="list"]'; -export const sortablePreviousItemIdAttribute = 'data-sortable-lists-prev-item-id'; - export interface SortableItemData extends Record { [sortableItemDataKey]:true; type:string; @@ -128,14 +128,6 @@ export function buildMoveFormData({ return data; } -export function resolveItemId(element:Element):string|null { - return element.getAttribute('data-sortable-lists--item-id-value'); -} - -export function resolveItemType(element:Element):string { - return element.getAttribute('data-sortable-lists--item-type-value') ?? 'item'; -} - export function resolveListData(element:Element):SortableListData|null { const list = element.closest(sortableListSelector); @@ -175,27 +167,6 @@ export function isSourceListTarget({ return sourceElement.closest(sortableListSelector) === targetElement; } -function resolveClosestItemElement(element:Element):HTMLElement|null { - if (element instanceof HTMLElement && element.matches(sortableItemSelector)) { - return element; - } - - return element.closest(sortableItemSelector); -} - -function resolveItemElement(element:Element):HTMLElement|null { - return resolveClosestItemElement(element) ?? - element.querySelector(sortableItemSelector); -} - -function resolvePreviousItemId(element:Element):string|null { - const item = resolveItemElement(element); - - // Non-item rows, such as truncated "show more" rows, can mark the last - // omitted item so position resolution remains correct in sparse lists. - return item ? resolveItemId(item) : element.getAttribute(sortablePreviousItemIdAttribute); -} - function elementsFromPoint(document:Document, clientX:number, clientY:number):Element[] { const elements = document.elementsFromPoint?.(clientX, clientY) ?? []; const element = document.elementFromPoint(clientX, clientY); @@ -289,96 +260,3 @@ export function resolvePreviousSortableItemId({ return null; } - -export interface RowPlacement { - row:HTMLElement; - parent:Node|null; - nextSibling:Node|null; -} - -// Snapshot each row's current location so an optimistic move can be undone if -// the server rejects it. Captured before the move; restored in reverse so the -// stored nextSibling references are still valid when reinserting. -export function captureRowPositions(rows:HTMLElement[]):RowPlacement[] { - return rows.map((row) => ({ - row, - parent: row.parentNode, - nextSibling: row.nextSibling, - })); -} - -export function restoreRowPositions(positions:RowPlacement[]):void { - for (let i = positions.length - 1; i >= 0; i -= 1) { - const { row, parent, nextSibling } = positions[i]; - parent?.insertBefore(row, nextSibling); - } -} - -// Optimistically move rows on the client without waiting for the server. -// `rows` are the moved
        • s in order (one today, the selected set once -// multi-item DnD lands); `previousItemId` of null means top of list. -export function reorderRows({ - rows, - list, - previousItemId, -}:{ - rows:HTMLElement[]; - list:HTMLElement; - previousItemId:string|null; -}):void { - let anchor:Element|null = previousItemId ? resolveAnchorRow(list, previousItemId) : null; - - for (const row of rows) { - if (anchor) { - anchor.after(row); - } else { - insertAtListTop(list, row); - } - - anchor = row; - } -} - -// The previous item id can point at a hidden item collapsed behind a truncation -// marker row, which carries the id on data-sortable-lists-prev-item-id rather -// than exposing an item element. Anchor on that marker so the row lands next to -// the collapsed block instead of jumping to the top. -function resolveAnchorRow(list:HTMLElement, previousItemId:string):HTMLElement|null { - const escaped = CSS.escape(previousItemId); - const anchor = list.querySelector(`[data-sortable-lists--item-id-value="${escaped}"]`) - ?? list.querySelector(`[${sortablePreviousItemIdAttribute}="${escaped}"]`); - - return anchor?.closest('li') ?? null; -} - -// Rows can sit directly under the list element or inside a nested
            , so a -// plain list.prepend() would drop the row before that
              . Insert before the -// first existing row instead, keeping it in the same container as its siblings. -function insertAtListTop(list:HTMLElement, row:HTMLElement):void { - const firstRow = list.querySelector(':scope > li, :scope > ul > li'); - - if (firstRow && firstRow !== row) { - firstRow.before(row); - } else if (!firstRow) { - (list.querySelector(':scope > ul') ?? list).prepend(row); - } -} - -export function resolveListAppendPreviousItemId({ - sourceItemId, - list, -}:{ - sourceItemId:string; - list:Element; -}):string|null { - const rows = Array.from(list.querySelectorAll(':scope > li, :scope > ul > li')).reverse(); - - for (const row of rows) { - const itemId = resolvePreviousItemId(row); - if (itemId && itemId !== sourceItemId) { - return itemId; - } - } - - return null; -} diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index 65153d37bc64..29f3cae26d3c 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -39,12 +39,14 @@ import { Controller } from '@hotwired/stimulus'; import { closestInteractiveElement } from 'core-stimulus/helpers/interactive-element-helper'; import { isSortableItemData, - sortableItemSelector, sortableItemData, - sortableListsMovingAttribute, - sortableListsRootSelector, type SortableItemData, } from './drag-and-drop'; +import { + sortableItemSelector, + sortableListsMovingAttribute, + sortableListsRootSelector, +} from './list-dom'; type CleanupFn = () => void; diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts new file mode 100644 index 000000000000..4e708e3c7d75 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts @@ -0,0 +1,189 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { + captureRowPositions, + reorderRows, + resolveItemType, + resolveListAppendPreviousItemId, + restoreRowPositions, +} from './list-dom'; + +describe('sortable lists DOM helpers', () => { + function itemRow(id:string):HTMLLIElement { + const row = document.createElement('li'); + const item = document.createElement('article'); + + row.setAttribute('data-sortable-lists--item-id-value', id); + row.appendChild(item); + + return row; + } + + function showMoreRow(previousItemId = 'hidden-item'):HTMLLIElement { + const row = document.createElement('li'); + + row.setAttribute('data-sortable-lists-prev-item-id', previousItemId); + + return row; + } + + function listElement():HTMLUListElement { + const list = document.createElement('ul'); + + list.setAttribute('data-sortable-lists-target', 'list'); + + return list; + } + + function itemIdOrder(list:HTMLElement):string[] { + return Array.from(list.querySelectorAll('[data-sortable-lists--item-id-value]')) + .map((element) => element.getAttribute('data-sortable-lists--item-id-value')!); + } + + describe('resolveItemType', () => { + it('reads the item type Stimulus value', () => { + const item = itemRow('1'); + + item.setAttribute('data-sortable-lists--item-type-value', 'work_package'); + + expect(resolveItemType(item)).toEqual('work_package'); + }); + + it('uses a generic item type when no item type value is present', () => { + expect(resolveItemType(itemRow('1'))).toEqual('item'); + }); + }); + + describe('resolveListAppendPreviousItemId', () => { + it('returns the last item in a list while skipping the source and truncation marker rows', () => { + const list = listElement(); + + list.append(itemRow('1'), showMoreRow(), itemRow('2'), itemRow('3')); + + expect(resolveListAppendPreviousItemId({ sourceItemId: '3', list })).toEqual('2'); + }); + + it('returns null when the list has no other items', () => { + const list = listElement(); + + list.append(itemRow('1')); + + expect(resolveListAppendPreviousItemId({ sourceItemId: '1', list })).toBeNull(); + }); + }); + + describe('reorderRows', () => { + it('moves a row to sit immediately after the previous item anchor', () => { + const list = listElement(); + const [one, two, three] = ['1', '2', '3'].map(itemRow); + + list.append(one, two, three); + reorderRows({ rows: [one], list, previousItemId: '2' }); + + expect(itemIdOrder(list)).toEqual(['2', '1', '3']); + }); + + it('moves a row to the top of the list before the first existing row', () => { + const list = listElement(); + const [one, two, three] = ['1', '2', '3'].map(itemRow); + + list.append(one, two, three); + reorderRows({ rows: [three], list, previousItemId: null }); + + expect(itemIdOrder(list)).toEqual(['3', '1', '2']); + }); + + it('keeps a top-of-list move inside a nested list element instead of escaping it', () => { + const list = document.createElement('div'); + const inner = document.createElement('ul'); + const [one, two, three] = ['1', '2', '3'].map(itemRow); + + list.setAttribute('data-sortable-lists-target', 'list'); + inner.append(one, two, three); + list.append(inner); + + reorderRows({ rows: [three], list, previousItemId: null }); + + expect(three.parentElement).toBe(inner); + expect(itemIdOrder(list)).toEqual(['3', '1', '2']); + }); + + it('inserts a moved group after the anchor preserving their order', () => { + const list = listElement(); + const [one, two, three, four] = ['1', '2', '3', '4'].map(itemRow); + + list.append(one, two, three, four); + reorderRows({ rows: [three, four], list, previousItemId: '1' }); + + expect(itemIdOrder(list)).toEqual(['1', '3', '4', '2']); + }); + + it('anchors on a truncation marker row when the previous item is hidden', () => { + const list = listElement(); + const [one, two, three] = ['1', '2', '3'].map(itemRow); + const marker = showMoreRow('hidden'); + + list.append(three, one, marker, two); + reorderRows({ rows: [three], list, previousItemId: 'hidden' }); + + expect(three.previousElementSibling).toBe(marker); + expect(itemIdOrder(list)).toEqual(['1', '3', '2']); + }); + }); + + describe('captureRowPositions / restoreRowPositions', () => { + it('restores a row to its original position after an optimistic move', () => { + const list = listElement(); + const [one, two, three] = ['1', '2', '3'].map(itemRow); + + list.append(one, two, three); + const snapshot = captureRowPositions([three]); + + reorderRows({ rows: [three], list, previousItemId: null }); + expect(itemIdOrder(list)).toEqual(['3', '1', '2']); + + restoreRowPositions(snapshot); + expect(itemIdOrder(list)).toEqual(['1', '2', '3']); + }); + + it('restores a multi-row group to its original order', () => { + const list = listElement(); + const [one, two, three, four] = ['1', '2', '3', '4'].map(itemRow); + + list.append(one, two, three, four); + const snapshot = captureRowPositions([two, three]); + + reorderRows({ rows: [two, three], list, previousItemId: '4' }); + expect(itemIdOrder(list)).toEqual(['1', '4', '2', '3']); + + restoreRowPositions(snapshot); + expect(itemIdOrder(list)).toEqual(['1', '2', '3', '4']); + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts new file mode 100644 index 000000000000..f186e95d772f --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts @@ -0,0 +1,175 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +// Sortable lists use a DOM contract shared by the root and item controllers: +// the root has data-controller~="sortable-lists"; lists are root targets with +// data-sortable-lists-list-* metadata; items expose sortable-lists--item values; +// sparse non-item rows may expose data-sortable-lists-prev-item-id. +// +// This module holds the drag-and-drop-agnostic half of that contract: reading +// items and rows out of the DOM and moving rows around. The Pragmatic DnD +// payloads built on top of it live in drag-and-drop.ts. +export const sortableListsMovingAttribute = 'data-sortable-lists-moving'; +export const sortableListsRootSelector = '[data-controller~="sortable-lists"]'; +export const sortableItemSelector = '[data-sortable-lists--item-id-value]'; +export const sortableListSelector = '[data-sortable-lists-target~="list"]'; +export const sortablePreviousItemIdAttribute = 'data-sortable-lists-prev-item-id'; + +// Rows can sit directly under the list element or inside a nested
                . +const listRowSelector = ':scope > li, :scope > ul > li'; + +function listRows(list:Element):Element[] { + return Array.from(list.querySelectorAll(listRowSelector)); +} + +function firstListRow(list:Element):Element|null { + return list.querySelector(listRowSelector); +} + +export function resolveItemId(element:Element):string|null { + return element.getAttribute('data-sortable-lists--item-id-value'); +} + +export function resolveItemType(element:Element):string { + return element.getAttribute('data-sortable-lists--item-type-value') ?? 'item'; +} + +export function resolveClosestItemElement(element:Element):HTMLElement|null { + if (element instanceof HTMLElement && element.matches(sortableItemSelector)) { + return element; + } + + return element.closest(sortableItemSelector); +} + +export function resolveItemElement(element:Element):HTMLElement|null { + return resolveClosestItemElement(element) ?? + element.querySelector(sortableItemSelector); +} + +export function resolvePreviousItemId(element:Element):string|null { + const item = resolveItemElement(element); + + // Non-item rows, such as truncated "show more" rows, can mark the last + // omitted item so position resolution remains correct in sparse lists. + return item ? resolveItemId(item) : element.getAttribute(sortablePreviousItemIdAttribute); +} + +// The inverse of resolvePreviousItemId: the previous item id can point at a +// hidden item collapsed behind a truncation marker row, which carries the id +// on data-sortable-lists-prev-item-id rather than exposing an item element. +// Anchor on that marker so the row lands next to the collapsed block instead +// of jumping to the top. +function resolveAnchorRow(list:HTMLElement, previousItemId:string):HTMLElement|null { + const escaped = CSS.escape(previousItemId); + const anchor = list.querySelector(`[data-sortable-lists--item-id-value="${escaped}"]`) + ?? list.querySelector(`[${sortablePreviousItemIdAttribute}="${escaped}"]`); + + return anchor?.closest('li') ?? null; +} + +export function resolveListAppendPreviousItemId({ + sourceItemId, + list, +}:{ + sourceItemId:string; + list:Element; +}):string|null { + const rows = listRows(list).reverse(); + + for (const row of rows) { + const itemId = resolvePreviousItemId(row); + if (itemId && itemId !== sourceItemId) { + return itemId; + } + } + + return null; +} + +export interface RowPlacement { + row:HTMLElement; + parent:Node|null; + nextSibling:Node|null; +} + +// Snapshot each row's current location so an optimistic move can be undone if +// the server rejects it. Captured before the move; restored in reverse so the +// stored nextSibling references are still valid when reinserting. +export function captureRowPositions(rows:HTMLElement[]):RowPlacement[] { + return rows.map((row) => ({ + row, + parent: row.parentNode, + nextSibling: row.nextSibling, + })); +} + +export function restoreRowPositions(positions:RowPlacement[]):void { + for (let i = positions.length - 1; i >= 0; i -= 1) { + const { row, parent, nextSibling } = positions[i]; + parent?.insertBefore(row, nextSibling); + } +} + +// Optimistically move rows on the client without waiting for the server. +// `rows` are the moved
              • s in order (one today, the selected set once +// multi-item DnD lands); `previousItemId` of null means top of list. +export function reorderRows({ + rows, + list, + previousItemId, +}:{ + rows:HTMLElement[]; + list:HTMLElement; + previousItemId:string|null; +}):void { + let anchor:Element|null = previousItemId ? resolveAnchorRow(list, previousItemId) : null; + + for (const row of rows) { + if (anchor) { + anchor.after(row); + } else { + insertAtListTop(list, row); + } + + anchor = row; + } +} + +// A plain list.prepend() would drop the row before a nested
                  . Insert +// before the first existing row instead, keeping it in the same container as +// its siblings. +function insertAtListTop(list:HTMLElement, row:HTMLElement):void { + const firstRow = firstListRow(list); + + if (firstRow && firstRow !== row) { + firstRow.before(row); + } else if (!firstRow) { + (list.querySelector(':scope > ul') ?? list).prepend(row); + } +} From 1f5f8a0978baba0c39ee03b1605a151f16d4c09f Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 9 Jun 2026 20:20:11 +0100 Subject: [PATCH 010/105] Extract drop intent resolution from controller Moves the ~40-line target and previous-item derivation from the sortable lists controller's `handleDrop` into the pure `resolveDropIntent` helper that can be unit tested without Stimulus or Pragmatic DnD scaffolding. --- .../dynamic/sortable-lists.controller.ts | 59 +---- .../sortable-lists/drag-and-drop.spec.ts | 237 ++++++++++++++---- .../dynamic/sortable-lists/drag-and-drop.ts | 69 ++++- 3 files changed, 268 insertions(+), 97 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 0080b67d9144..c9e30e545d91 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -27,7 +27,6 @@ //++ import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; -import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { dropTargetForElements, monitorForElements, @@ -44,16 +43,13 @@ import { acceptsSortableItemType, buildMoveFormData, isSortableItemData, - isSourceListTarget, - resolveFallbackDropTarget, + resolveDropIntent, resolveListData, - resolvePreviousSortableItemId, type SortableListData, } from './sortable-lists/drag-and-drop'; import { captureRowPositions, reorderRows, - resolveListAppendPreviousItemId, restoreRowPositions, sortableItemSelector, sortableListSelector, @@ -204,49 +200,18 @@ export default class SortableListsController extends Controller { return; } - const targetItem = location.current.dropTargets.find(({ data, element }) => ( - isSortableItemData(data) && element instanceof HTMLElement && this.element.contains(element) - )); - const targetList = location.current.dropTargets.find(({ element }) => ( - element instanceof HTMLElement && this.element.contains(element) - )); - const fallbackTarget = location.current.dropTargets.length === 0 - ? resolveFallbackDropTarget({ - input: location.current.input, - root: this.element, - sourceElement: source.element, - }) - : null; - const fallbackItem = fallbackTarget?.isItem ? fallbackTarget : null; - const resolvedTargetItem = targetItem ?? fallbackItem; - const targetElement = resolvedTargetItem?.element ?? targetList?.element ?? fallbackTarget?.element; - - if (!(targetElement instanceof HTMLElement)) { - return; - } - - const listData = resolveListData(targetElement); - if (!listData) { - return; - } - - if (!resolvedTargetItem && isSourceListTarget({ sourceElement: source.element, targetElement })) { + const intent = resolveDropIntent({ + location, + root: this.element, + sourceElement: source.element, + sourceData: source.data, + }); + if (!intent) { return; } - const previousItemId = resolvedTargetItem?.element instanceof HTMLElement - ? resolvePreviousSortableItemId({ - sourceItemId: source.data.itemId, - targetItem: resolvedTargetItem.element, - closestEdge: extractClosestEdge(resolvedTargetItem.data), - }) - : resolveListAppendPreviousItemId({ - sourceItemId: source.data.itemId, - list: targetElement, - }); - const sourceRow = source.element.closest('li'); - const listElement = targetElement.closest(sortableListSelector); + const listElement = intent.targetElement.closest(sortableListSelector); if (!(sourceRow instanceof HTMLElement) || !(listElement instanceof HTMLElement)) { return; } @@ -256,11 +221,11 @@ export default class SortableListsController extends Controller { // back to where it started. const rows = [sourceRow]; const rollback = captureRowPositions(rows); - reorderRows({ rows, list: listElement, previousItemId }); + reorderRows({ rows, list: listElement, previousItemId: intent.previousItemId }); const moved = await this.moveItem({ - listData, - previousItemId, + listData: intent.listData, + previousItemId: intent.previousItemId, moveUrl, }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts index a301f83484a7..db2870d89293 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -28,12 +28,14 @@ import { vi } from 'vitest'; -import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { type DragLocationHistory } from '@atlaskit/pragmatic-drag-and-drop/types'; import { acceptsSortableItemType, buildMoveFormData, isSortableItemData, isSortableListData, + resolveDropIntent, resolveFallbackDropTarget, resolveListData, resolvePreviousSortableItemId, @@ -60,6 +62,54 @@ describe('sortable lists drag and drop helpers', () => { return row; } + function input({ clientX = 10, clientY = 10 } = {}) { + return { + altKey: false, + button: 0, + buttons: 0, + ctrlKey: false, + metaKey: false, + shiftKey: false, + clientX, + clientY, + pageX: clientX, + pageY: clientY, + }; + } + + function rect():DOMRect { + return { + top: 0, + bottom: 100, + left: 0, + right: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }; + } + + function stubElementFromPoint(element:Element) { + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => element), + }); + } + + function stubElementsFromPoint(elements:Element[]) { + Object.defineProperty(document, 'elementsFromPoint', { + configurable: true, + value: vi.fn(() => elements), + }); + } + + afterEach(() => { + vi.restoreAllMocks(); + document.body.replaceChildren(); + }); + describe('isSortableItemData', () => { it('accepts backlogs item data', () => { expect(isSortableItemData(sortableItemData({ type: 'work_package', itemId: '42' }))).toBe(true); @@ -238,54 +288,6 @@ describe('sortable lists drag and drop helpers', () => { }); describe('resolveFallbackDropTarget', () => { - function input({ clientX = 10, clientY = 10 } = {}) { - return { - altKey: false, - button: 0, - buttons: 0, - ctrlKey: false, - metaKey: false, - shiftKey: false, - clientX, - clientY, - pageX: clientX, - pageY: clientY, - }; - } - - function rect():DOMRect { - return { - top: 0, - bottom: 100, - left: 0, - right: 100, - width: 100, - height: 100, - x: 0, - y: 0, - toJSON: () => ({}), - }; - } - - function stubElementFromPoint(element:Element) { - Object.defineProperty(document, 'elementFromPoint', { - configurable: true, - value: vi.fn(() => element), - }); - } - - function stubElementsFromPoint(elements:Element[]) { - Object.defineProperty(document, 'elementsFromPoint', { - configurable: true, - value: vi.fn(() => elements), - }); - } - - afterEach(() => { - vi.restoreAllMocks(); - document.body.replaceChildren(); - }); - it('resolves an item at the drop coordinates', () => { const root = document.createElement('div'); const list = document.createElement('div'); @@ -427,4 +429,141 @@ describe('sortable lists drag and drop helpers', () => { }); }); + describe('resolveDropIntent', () => { + function dropLocation({ + dropTargets = [], + clientX = 10, + clientY = 10, + }:{ + dropTargets?:{ data:Record; element:Element }[]; + clientX?:number; + clientY?:number; + } = {}):DragLocationHistory { + return { + current: { dropTargets, input: input({ clientX, clientY }) }, + } as unknown as DragLocationHistory; + } + + function buildList({ listId = '7' } = {}) { + const root = document.createElement('div'); + const list = document.createElement('ul'); + + list.setAttribute('data-sortable-lists-target', 'list'); + list.setAttribute('data-sortable-lists-list-type', 'backlog_bucket'); + list.setAttribute('data-sortable-lists-list-id', listId); + root.appendChild(list); + + return { root, list }; + } + + it('resolves a drop on an item to the position implied by the edge', () => { + const { root, list } = buildList(); + const source = itemRow('1'); + const target = itemRow('2'); + + list.append(source, target); + document.body.appendChild(root); + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect()); + + const data = attachClosestEdge(sortableItemData({ type: 'work_package', itemId: '2' }), { + element: target, + input: input({ clientY: 90 }), + allowedEdges: ['top', 'bottom'], + }); + + const intent = resolveDropIntent({ + location: dropLocation({ dropTargets: [{ data, element: target }] }), + root, + sourceElement: source, + sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), + }); + + expect(intent?.targetElement).toBe(target); + expect(intent?.listData).toEqual(expect.objectContaining({ type: 'backlog_bucket', listId: '7' })); + expect(intent?.previousItemId).toEqual('2'); + }); + + it('appends to the list when the drop target is the list itself', () => { + const { root, list } = buildList(); + const sourceList = document.createElement('ul'); + const source = itemRow('1'); + + sourceList.setAttribute('data-sortable-lists-target', 'list'); + sourceList.setAttribute('data-sortable-lists-list-type', 'sprint'); + sourceList.setAttribute('data-sortable-lists-list-id', '3'); + sourceList.append(source); + list.append(itemRow('4'), itemRow('5')); + root.append(sourceList); + + const intent = resolveDropIntent({ + location: dropLocation({ + dropTargets: [{ data: sortableListData({ type: 'backlog_bucket', listId: '7' }), element: list }], + }), + root, + sourceElement: source, + sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), + }); + + expect(intent?.targetElement).toBe(list); + expect(intent?.listData).toEqual(expect.objectContaining({ type: 'backlog_bucket', listId: '7' })); + expect(intent?.previousItemId).toEqual('5'); + }); + + it('returns null for a drop back onto the source list without a target item', () => { + const { root, list } = buildList(); + const source = itemRow('1'); + + list.append(source, itemRow('2')); + + const intent = resolveDropIntent({ + location: dropLocation({ + dropTargets: [{ data: sortableListData({ type: 'backlog_bucket', listId: '7' }), element: list }], + }), + root, + sourceElement: source, + sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), + }); + + expect(intent).toBeNull(); + }); + + it('falls back to pointer hit-testing when no drop targets are reported', () => { + const { root, list } = buildList(); + const source = itemRow('1'); + const target = itemRow('2'); + + list.append(source, target); + document.body.appendChild(root); + stubElementFromPoint(target.querySelector('article')!); + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect()); + + const intent = resolveDropIntent({ + location: dropLocation({ clientY: 90 }), + root, + sourceElement: source, + sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), + }); + + expect(intent?.targetElement).toBe(target); + expect(intent?.previousItemId).toEqual('2'); + }); + + it('returns null when the drop lands outside the root', () => { + const { root } = buildList(); + const source = itemRow('1'); + const outside = document.createElement('div'); + + document.body.append(root, outside); + stubElementFromPoint(outside); + + const intent = resolveDropIntent({ + location: dropLocation(), + root, + sourceElement: source, + sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), + }); + + expect(intent).toBeNull(); + }); + }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index 9391cd67a718..2ffea0a51cfd 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -29,13 +29,15 @@ import { attachClosestEdge, type Edge, + extractClosestEdge, } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; -import { type Input } from '@atlaskit/pragmatic-drag-and-drop/types'; +import { type DragLocationHistory, type Input } from '@atlaskit/pragmatic-drag-and-drop/types'; import { resolveClosestItemElement, resolveItemElement, resolveItemId, resolveItemType, + resolveListAppendPreviousItemId, resolvePreviousItemId, sortableListSelector, } from './list-dom'; @@ -260,3 +262,68 @@ export function resolvePreviousSortableItemId({ return null; } + +export interface DropIntent { + targetElement:HTMLElement; + listData:SortableListData; + previousItemId:string|null; +} + +// Resolve where a dropped item should land: the element under the drop, the +// list it belongs to and the item the dropped one should be inserted after. +// Returns null when the drop does not amount to a move (outside the root, no +// list metadata, or dropped back onto its own list without a target item). +export function resolveDropIntent({ + location, + root, + sourceElement, + sourceData, +}:{ + location:DragLocationHistory; + root:HTMLElement; + sourceElement:HTMLElement; + sourceData:SortableItemData; +}):DropIntent|null { + const targetItem = location.current.dropTargets.find(({ data, element }) => ( + isSortableItemData(data) && element instanceof HTMLElement && root.contains(element) + )); + const targetList = location.current.dropTargets.find(({ element }) => ( + element instanceof HTMLElement && root.contains(element) + )); + const fallbackTarget = location.current.dropTargets.length === 0 + ? resolveFallbackDropTarget({ + input: location.current.input, + root, + sourceElement, + }) + : null; + const fallbackItem = fallbackTarget?.isItem ? fallbackTarget : null; + const resolvedTargetItem = targetItem ?? fallbackItem; + const targetElement = resolvedTargetItem?.element ?? targetList?.element ?? fallbackTarget?.element; + + if (!(targetElement instanceof HTMLElement)) { + return null; + } + + const listData = resolveListData(targetElement); + if (!listData) { + return null; + } + + if (!resolvedTargetItem && isSourceListTarget({ sourceElement, targetElement })) { + return null; + } + + const previousItemId = resolvedTargetItem?.element instanceof HTMLElement + ? resolvePreviousSortableItemId({ + sourceItemId: sourceData.itemId, + targetItem: resolvedTargetItem.element, + closestEdge: extractClosestEdge(resolvedTargetItem.data), + }) + : resolveListAppendPreviousItemId({ + sourceItemId: sourceData.itemId, + list: targetElement, + }); + + return { targetElement, listData, previousItemId }; +} From ac12abd939132582ea4434af277e4c675bab3dd5 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 00:02:53 +0100 Subject: [PATCH 011/105] Preserve Pragmatic DnD morph markers Turbo morphs must keep client-only drag markers so Pragmatic DnD remains registered across server-rendered morph updates. --- .../pragmatic-dnd-morph-attributes.spec.ts | 79 +++++++++++++++++++ .../turbo/pragmatic-dnd-morph-attributes.ts | 49 ++++++++++++ frontend/src/turbo/setup.ts | 2 + 3 files changed, 130 insertions(+) create mode 100644 frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts create mode 100644 frontend/src/turbo/pragmatic-dnd-morph-attributes.ts diff --git a/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts b/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts new file mode 100644 index 000000000000..df2be49c8801 --- /dev/null +++ b/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts @@ -0,0 +1,79 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { registerPragmaticDndMorphAttributePreservation } from './pragmatic-dnd-morph-attributes'; + +describe('Pragmatic DnD morph attribute preservation', () => { + function beforeMorphAttributeEvent(attributeName:string, mutationType = 'remove') { + return new CustomEvent('turbo:before-morph-attribute', { + bubbles: true, + cancelable: true, + detail: { attributeName, mutationType }, + }); + } + + beforeEach(() => { + registerPragmaticDndMorphAttributePreservation(); + }); + + afterEach(() => { + document.body.replaceChildren(); + }); + + it('preserves Pragmatic DnD drop target markers during Turbo morphs', () => { + const element = document.createElement('li'); + const event = beforeMorphAttributeEvent('data-drop-target-for-element'); + + element.setAttribute('data-drop-target-for-element', 'true'); + document.body.appendChild(element); + element.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + + it('preserves the active drag marker during Turbo morphs', () => { + const element = document.createElement('li'); + const event = beforeMorphAttributeEvent('data-dragging'); + + element.setAttribute('data-dragging', 'source'); + document.body.appendChild(element); + element.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + + it('lets unrelated attributes continue morphing', () => { + const element = document.createElement('li'); + const event = beforeMorphAttributeEvent('class'); + + document.body.appendChild(element); + element.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + }); +}); diff --git a/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts b/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts new file mode 100644 index 000000000000..a4d4e131f7a0 --- /dev/null +++ b/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts @@ -0,0 +1,49 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +const preservedAttributes = new Set([ + 'data-dragging', + 'data-drop-target-for-element', +]); + +let registered = false; + +export function registerPragmaticDndMorphAttributePreservation():void { + if (registered) { + return; + } + + registered = true; + document.addEventListener('turbo:before-morph-attribute', (event) => { + if (!preservedAttributes.has(event.detail?.attributeName)) { + return; + } + + event.preventDefault(); + }); +} diff --git a/frontend/src/turbo/setup.ts b/frontend/src/turbo/setup.ts index c86857fda039..87963c84bedd 100644 --- a/frontend/src/turbo/setup.ts +++ b/frontend/src/turbo/setup.ts @@ -12,6 +12,7 @@ import { getTurboEvents } from './utils'; import { StreamActions } from '@hotwired/turbo'; import { addTurboAngularWrapper } from 'core-turbo/turbo-angular-wrapper'; import { registerActionMenuMorphRemount } from './action-menu-morph-remount'; +import { registerPragmaticDndMorphAttributePreservation } from './pragmatic-dnd-morph-attributes'; Turbo.session.drive = true; Turbo.config.drive.progressBarDelay = 100; @@ -39,6 +40,7 @@ whenDebugging(() => { addTurboEventListeners(); addTurboGlobalListeners(); registerActionMenuMorphRemount(); +registerPragmaticDndMorphAttributePreservation(); registerDialogStreamAction(); registerFlashStreamAction(); registerLiveRegionStreamAction(); From 98b60e9298fec7daaea2c6125bbe0e28d1d1a54b Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 00:05:24 +0100 Subject: [PATCH 012/105] Tighten sortable drop intent Sortable targets should use their tagged payloads so canceled drags and unrelated drop targets cannot become move requests. --- .../dynamic/sortable-lists.controller.spec.ts | 34 +++++++++++-- .../dynamic/sortable-lists.controller.ts | 7 ++- .../sortable-lists/drag-and-drop.spec.ts | 31 ++++++++++-- .../dynamic/sortable-lists/drag-and-drop.ts | 23 +++------ .../sortable-lists/item.controller.spec.ts | 49 +++++++++++++++++++ .../dynamic/sortable-lists/item.controller.ts | 13 ++++- 6 files changed, 130 insertions(+), 27 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 9afb3b093498..1b381b0745ea 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -35,7 +35,10 @@ import type { dropTargetForElements as dropTargetForElementsFn, monitorForElemen import type { FetchRequest as FetchRequestFn } from '@rails/request.js'; import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; import type SortableListsControllerType from './sortable-lists.controller'; -import type { sortableItemData as sortableItemDataFn } from './sortable-lists/drag-and-drop'; +import type { + sortableItemData as sortableItemDataFn, + sortableListData as sortableListDataFn, +} from './sortable-lists/drag-and-drop'; describe('Sortable lists controller', () => { const flushPromises = () => new Promise((resolve) => setTimeout(resolve)); @@ -46,6 +49,7 @@ describe('Sortable lists controller', () => { let autoScrollForElements:typeof autoScrollForElementsFn; let SortableListsController:typeof SortableListsControllerType; let sortableItemData:typeof sortableItemDataFn; + let sortableListData:typeof sortableListDataFn; let ctx:StimulusTestContext; let fixture:HTMLElement; @@ -74,7 +78,7 @@ describe('Sortable lists controller', () => { ({ dropTargetForElements, monitorForElements } = await import('@atlaskit/pragmatic-drag-and-drop/element/adapter')); ({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element')); ({ default: SortableListsController } = await import('./sortable-lists.controller')); - ({ sortableItemData } = await import('./sortable-lists/drag-and-drop')); + ({ sortableItemData, sortableListData } = await import('./sortable-lists/drag-and-drop')); }); function input() { @@ -148,6 +152,16 @@ describe('Sortable lists controller', () => { return fixture.querySelector('[data-sortable-lists-target="scrollable"]')!; } + function renderInvalidListFixture() { + fixture.innerHTML = ` +
                  +
                    +
                    + `; + + return fixture.querySelector('[data-sortable-lists-target="list"]')!; + } + async function dropCurrentItemOnList(sourceElement:HTMLElement, list:HTMLElement) { const monitorOptions = vi.mocked(monitorForElements).mock.lastCall?.[0]; @@ -169,10 +183,10 @@ describe('Sortable lists controller', () => { dropTargets: [ dropTargetRecord( list, - { - type: list.getAttribute('data-sortable-lists-list-type'), + sortableListData({ + type: list.getAttribute('data-sortable-lists-list-type')!, listId: list.getAttribute('data-sortable-lists-list-id'), - }, + }), ), ], input: input(), @@ -521,6 +535,16 @@ describe('Sortable lists controller', () => { })); }); + it('does not register list targets without sortable list data', async () => { + const list = renderInvalidListFixture(); + + await ctx.nextFrame(); + + expect(dropTargetForElements).not.toHaveBeenCalledWith(expect.objectContaining({ + element: list, + })); + }); + it('accepts item drops when the source type matches the root accepted type', async () => { const { targetList, firstSourceItem } = renderFixture({ acceptedType: 'work_package' }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index c9e30e545d91..fccc9428a195 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -110,13 +110,18 @@ export default class SortableListsController extends Controller { } listTargetConnected(element:HTMLElement):void { + const listData = resolveListData(element); + if (!listData) { + return; + } + const cleanup = dropTargetForElements({ element, canDrop: ({ source }) => !this.moving && isSortableItemData(source.data) && acceptsSortableItemType({ acceptedType: this.acceptedType, type: source.data.type, }), - getData: () => resolveListData(element) ?? {}, + getData: () => listData, getIsSticky: () => false, }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts index db2870d89293..85e026ac8e3f 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -527,7 +527,7 @@ describe('sortable lists drag and drop helpers', () => { expect(intent).toBeNull(); }); - it('falls back to pointer hit-testing when no drop targets are reported', () => { + it('treats an empty drop target list as no move', () => { const { root, list } = buildList(); const source = itemRow('1'); const target = itemRow('2'); @@ -535,7 +535,6 @@ describe('sortable lists drag and drop helpers', () => { list.append(source, target); document.body.appendChild(root); stubElementFromPoint(target.querySelector('article')!); - vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect()); const intent = resolveDropIntent({ location: dropLocation({ clientY: 90 }), @@ -544,8 +543,32 @@ describe('sortable lists drag and drop helpers', () => { sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), }); - expect(intent?.targetElement).toBe(target); - expect(intent?.previousItemId).toEqual('2'); + expect(intent).toBeNull(); + }); + + it('ignores drop targets that are neither items nor lists', () => { + const { root, list } = buildList(); + const sourceList = document.createElement('ul'); + const source = itemRow('1'); + const header = document.createElement('header'); + + sourceList.setAttribute('data-sortable-lists-target', 'list'); + sourceList.setAttribute('data-sortable-lists-list-type', 'sprint'); + sourceList.setAttribute('data-sortable-lists-list-id', '3'); + sourceList.append(source); + list.append(header, itemRow('4')); + root.append(sourceList); + + const intent = resolveDropIntent({ + location: dropLocation({ + dropTargets: [{ data: {}, element: header }], + }), + root, + sourceElement: source, + sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), + }); + + expect(intent).toBeNull(); }); it('returns null when the drop lands outside the root', () => { diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index 2ffea0a51cfd..270739dc787f 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -287,19 +287,10 @@ export function resolveDropIntent({ const targetItem = location.current.dropTargets.find(({ data, element }) => ( isSortableItemData(data) && element instanceof HTMLElement && root.contains(element) )); - const targetList = location.current.dropTargets.find(({ element }) => ( - element instanceof HTMLElement && root.contains(element) + const targetList = location.current.dropTargets.find(({ data, element }) => ( + isSortableListData(data) && element instanceof HTMLElement && root.contains(element) )); - const fallbackTarget = location.current.dropTargets.length === 0 - ? resolveFallbackDropTarget({ - input: location.current.input, - root, - sourceElement, - }) - : null; - const fallbackItem = fallbackTarget?.isItem ? fallbackTarget : null; - const resolvedTargetItem = targetItem ?? fallbackItem; - const targetElement = resolvedTargetItem?.element ?? targetList?.element ?? fallbackTarget?.element; + const targetElement = targetItem?.element ?? targetList?.element; if (!(targetElement instanceof HTMLElement)) { return null; @@ -310,15 +301,15 @@ export function resolveDropIntent({ return null; } - if (!resolvedTargetItem && isSourceListTarget({ sourceElement, targetElement })) { + if (!targetItem && isSourceListTarget({ sourceElement, targetElement })) { return null; } - const previousItemId = resolvedTargetItem?.element instanceof HTMLElement + const previousItemId = targetItem?.element instanceof HTMLElement ? resolvePreviousSortableItemId({ sourceItemId: sourceData.itemId, - targetItem: resolvedTargetItem.element, - closestEdge: extractClosestEdge(resolvedTargetItem.data), + targetItem: targetItem.element, + closestEdge: extractClosestEdge(targetItem.data), }) : resolveListAppendPreviousItemId({ sourceItemId: sourceData.itemId, diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index ad8970bb2a63..7bf467ea5d0e 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -236,6 +236,55 @@ describe('Sortable lists item controller', () => { })).toBe(false); }); + it('does not accept drops from another sortable lists root', () => { + const sourceRoot = document.createElement('div'); + const targetRoot = document.createElement('div'); + const sourceElement = document.createElement('article'); + const targetElement = document.createElement('article'); + + sourceRoot.setAttribute('data-controller', 'sortable-lists'); + targetRoot.setAttribute('data-controller', 'sortable-lists'); + sourceRoot.append(sourceElement); + targetRoot.append(targetElement); + document.body.append(sourceRoot, targetRoot); + connectedControllerFor(targetElement); + + expect(vi.mocked(dropTargetForElements).mock.lastCall?.[0].canDrop?.({ + element: targetElement, + input: {} as never, + source: { + data: sortableItemData({ type: 'item', itemId: '456' }), + element: sourceElement, + } as never, + })).toBe(false); + + sourceRoot.remove(); + targetRoot.remove(); + }); + + it('does not accept drops whose type is rejected by the root', () => { + const root = document.createElement('div'); + const sourceElement = document.createElement('article'); + const targetElement = document.createElement('article'); + + root.setAttribute('data-controller', 'sortable-lists'); + root.setAttribute('data-sortable-lists-accepted-type-value', 'work_package'); + root.append(sourceElement, targetElement); + document.body.append(root); + connectedControllerFor(targetElement); + + expect(vi.mocked(dropTargetForElements).mock.lastCall?.[0].canDrop?.({ + element: targetElement, + input: {} as never, + source: { + data: sortableItemData({ type: 'meeting_agenda_item', itemId: '456' }), + element: sourceElement, + } as never, + })).toBe(false); + + root.remove(); + }); + it('does not expose native external drag data', () => { const element = document.createElement('article'); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index 29f3cae26d3c..d907f88b1a35 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -38,6 +38,7 @@ import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unha import { Controller } from '@hotwired/stimulus'; import { closestInteractiveElement } from 'core-stimulus/helpers/interactive-element-helper'; import { + acceptsSortableItemType, isSortableItemData, sortableItemData, type SortableItemData, @@ -143,7 +144,17 @@ export default class ItemController extends Controller { return dropTargetForElements({ element: this.element, canDrop: ({ source }) => { - return isSortableItemData(source.data) && source.data.itemId !== this.idValue; + if (!isSortableItemData(source.data) || source.data.itemId === this.idValue) { + return false; + } + + const targetRoot = this.element.closest(sortableListsRootSelector); + const sourceRoot = source.element.closest(sortableListsRootSelector); + + return targetRoot === sourceRoot && acceptsSortableItemType({ + acceptedType: targetRoot?.getAttribute('data-sortable-lists-accepted-type-value') ?? null, + type: source.data.type, + }); }, getData: ({ input }) => { return attachClosestEdge(this.getItemData(), { From 760dbf42985848329e9333b2a9d9b14a65a17a94 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 00:06:50 +0100 Subject: [PATCH 013/105] Avoid duplicate move failure toasts Backlogs move errors can already render specific turbo-stream feedback, so only network and generic failures add a generic toast. --- .../dynamic/sortable-lists.controller.spec.ts | 48 +++++++++++++++++++ .../dynamic/sortable-lists.controller.ts | 24 +++++++--- .../dynamic/sortable-lists/list-dom.spec.ts | 12 +++++ .../dynamic/sortable-lists/list-dom.ts | 7 ++- 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 1b381b0745ea..15191f98a1fa 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -522,6 +522,54 @@ describe('Sortable lists controller', () => { expect(itemIds(targetList)).toEqual(['4', '5']); }); + it('does not dispatch a generic toast when a 422 turbo stream rejects the move', async () => { + const toastEvents:CustomEvent[] = []; + const onToast = (event:Event) => toastEvents.push(event as CustomEvent); + + window.addEventListener('op:toasters:add', onToast); + vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { + return { + perform: vi.fn(() => Promise.resolve({ ok: false, statusCode: 422 })), + }; + }); + + const { sourceList, targetList, firstSourceItem } = renderFixture(); + + await ctx.nextFrame(); + await dropCurrentItemOnList(firstSourceItem, targetList); + await flushPromises(); + + expect(itemIds(sourceList)).toEqual(['1', '2', '3']); + expect(itemIds(targetList)).toEqual(['4', '5']); + expect(toastEvents).toHaveLength(0); + + window.removeEventListener('op:toasters:add', onToast); + }); + + it('dispatches a generic toast when a non-422 response rejects the move', async () => { + const toastEvents:CustomEvent[] = []; + const onToast = (event:Event) => toastEvents.push(event as CustomEvent); + + window.addEventListener('op:toasters:add', onToast); + vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { + return { + perform: vi.fn(() => Promise.resolve({ ok: false, statusCode: 500 })), + }; + }); + + const { sourceList, targetList, firstSourceItem } = renderFixture(); + + await ctx.nextFrame(); + await dropCurrentItemOnList(firstSourceItem, targetList); + await flushPromises(); + + expect(itemIds(sourceList)).toEqual(['1', '2', '3']); + expect(itemIds(targetList)).toEqual(['4', '5']); + expect(toastEvents).toHaveLength(1); + + window.removeEventListener('op:toasters:add', onToast); + }); + it('registers Backlogs lists as drop targets', async () => { const { sourceList, targetList } = renderFixture(); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index fccc9428a195..7115f1c9a927 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -60,6 +60,7 @@ type CleanupFn = () => void; type ElementDropPayload = ElementEventPayloadMap['onDrop']; type AutoScrollAllowedAxis = 'vertical'|'horizontal'|'all'; type AutoScrollMaxScrollSpeed = 'standard'|'fast'; +type MoveResult = { ok:true }|{ ok:false; showToast:boolean }; const allowedAxes = new Set(['vertical', 'horizontal', 'all']); const maxScrollSpeeds = new Set(['standard', 'fast']); @@ -228,15 +229,22 @@ export default class SortableListsController extends Controller { const rollback = captureRowPositions(rows); reorderRows({ rows, list: listElement, previousItemId: intent.previousItemId }); - const moved = await this.moveItem({ + const result = await this.moveItem({ listData: intent.listData, previousItemId: intent.previousItemId, moveUrl, }); - if (!moved) { - flipMove(rows, () => restoreRowPositions(rollback)); - this.dispatchErrorToast(); + if (!result.ok) { + try { + flipMove(rows, () => restoreRowPositions(rollback)); + } catch (error) { + debugLog('Failed to roll back sortable list item move', error); + } + + if (result.showToast) { + this.dispatchErrorToast(); + } } } @@ -260,7 +268,7 @@ export default class SortableListsController extends Controller { listData:SortableListData; previousItemId:string|null; moveUrl:string; - }):Promise { + }):Promise { const request = new FetchRequest( 'put', moveUrl, @@ -282,10 +290,12 @@ export default class SortableListsController extends Controller { debugLog(`Failed to move sortable list item: ${response.statusCode}`); } - return response.ok; + return response.ok + ? { ok: true } + : { ok: false, showToast: response.statusCode !== 422 }; } catch (error) { debugLog('Failed to move sortable list item due to request error', error); - return false; + return { ok: false, showToast: true }; } finally { this.setMoving(false); } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts index 4e708e3c7d75..207f199b3d3d 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts @@ -185,5 +185,17 @@ describe('sortable lists DOM helpers', () => { restoreRowPositions(snapshot); expect(itemIdOrder(list)).toEqual(['1', '2', '3', '4']); }); + + it('falls back to appending when the captured next sibling is stale', () => { + const list = listElement(); + const [one, two] = ['1', '2'].map(itemRow); + + list.append(one, two); + const snapshot = captureRowPositions([one]); + two.remove(); + + expect(() => restoreRowPositions(snapshot)).not.toThrow(); + expect(itemIdOrder(list)).toEqual(['1']); + }); }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts index f186e95d772f..a48bb11b2a6b 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts @@ -132,7 +132,12 @@ export function captureRowPositions(rows:HTMLElement[]):RowPlacement[] { export function restoreRowPositions(positions:RowPlacement[]):void { for (let i = positions.length - 1; i >= 0; i -= 1) { const { row, parent, nextSibling } = positions[i]; - parent?.insertBefore(row, nextSibling); + if (!parent) { + continue; + } + + const insertionPoint = nextSibling?.parentNode === parent ? nextSibling : null; + parent.insertBefore(row, insertionPoint); } } From d4ddd2fc11b8603881d59742531d82dc769f5c17 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 00:08:25 +0100 Subject: [PATCH 014/105] Keep Backlogs list refresh connected The frame response must attach refresh behavior to loaded content because the outer src frame attributes are not replaced. --- .../backlogs/list-refresh.controller.spec.ts | 23 +++++++++++++++++++ .../backlogs/list-refresh.controller.ts | 8 +++++-- .../backlogs/backlog/_backlog_list.html.erb | 5 ++-- .../spec/features/inbox_column_spec.rb | 1 + .../spec/requests/backlogs/backlog_spec.rb | 2 +- .../backlogs/spec/support/pages/backlog.rb | 7 ++++++ 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.spec.ts index c0b2e07bee8a..f23b38341c7e 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.spec.ts @@ -83,6 +83,21 @@ describe('Backlogs list-refresh controller', () => { return { frame, reload }; } + async function renderFrameContent() { + await ctx.mount(` + +
                    +
                    + `); + const frame = ctx.container.querySelector('turbo-frame')!; + const reload = vi.fn(); + (frame as unknown as { reload:() => void }).reload = reload; + + await waitFor(() => { expect(aggregated$).toHaveBeenCalledWith('WorkPackage'); }); + + return { frame, reload }; + } + it('subscribes to aggregated WorkPackage events', async () => { await renderFrame(); @@ -97,6 +112,14 @@ describe('Backlogs list-refresh controller', () => { expect(reload).toHaveBeenCalledTimes(1); }); + it('reloads the containing frame when mounted on loaded frame content', async () => { + const { reload } = await renderFrameContent(); + + events$.next([{ eventType: 'updated' }]); + + expect(reload).toHaveBeenCalledTimes(1); + }); + it('ignores aggregated events without an updated entry', async () => { const { reload } = await renderFrame(); diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts index b95c2e6c5017..ad252b0c96b4 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts @@ -35,7 +35,7 @@ import { filter, Subscription } from 'rxjs'; // split pane or by the Angular layer. Those edits emit HAL events rather than a // Turbo stream targeting this frame, so subscribe to them and reload the frame // to keep the cards (and sprint point totals) in sync. -export default class ListRefreshController extends Controller { +export default class ListRefreshController extends Controller { private subscription:Subscription|null = null; private currentConnectionToken?:symbol; @@ -53,7 +53,7 @@ export default class ListRefreshController extends Controller { this.subscription = halEvents .aggregated$('WorkPackage') .pipe(filter((events) => events.some((event) => event.eventType === 'updated'))) - .subscribe(() => { void this.element.reload(); }); + .subscribe(() => { void this.frame?.reload(); }); } disconnect() { @@ -65,4 +65,8 @@ export default class ListRefreshController extends Controller { private isCurrentConnection(connectionToken:symbol):boolean { return this.element.isConnected && this.currentConnectionToken === connectionToken; } + + private get frame():FrameElement|null { + return this.element.closest('turbo-frame'); + } } diff --git a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb index df6827506de0..fa58da767624 100644 --- a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb @@ -29,10 +29,9 @@ See COPYRIGHT and LICENSE files for more details. <%= turbo_frame_tag "backlogs_container", refresh: :morph, - class: "op-backlogs-page", - data: { controller: "backlogs--list-refresh" } do %> + class: "op-backlogs-page" do %>
                    diff --git a/modules/backlogs/spec/features/inbox_column_spec.rb b/modules/backlogs/spec/features/inbox_column_spec.rb index f5b89637637f..260f91619302 100644 --- a/modules/backlogs/spec/features/inbox_column_spec.rb +++ b/modules/backlogs/spec/features/inbox_column_spec.rb @@ -485,6 +485,7 @@ details_view.expect_and_dismiss_toaster message: "Successful update." details_view.close + planning_page.expect_work_package_text_in_sprint(sprint_wp1, sprint, "Updated subject") planning_page.expect_no_inbox_show_more end diff --git a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb index 2ac7d9822fe5..b72f1c6348f1 100644 --- a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb +++ b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb @@ -87,7 +87,7 @@ expect(response).to have_turbo_frame "backlogs_container" expect(response.body).to include('class="op-sprint-planning-container"') - expect(response.body).to include('data-controller="sortable-lists"') + expect(response.body).to include('data-controller="backlogs--list-refresh sortable-lists"') expect(response.body).to include('data-sortable-lists-accepted-type-value="work_package"') expect(response.body).to include( %(data-sortable-lists-move-url-template-value="/projects/#{project.identifier}/backlogs/work_packages/{id}/move") diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index e0fa0f5d600d..02ffa8747ff2 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -233,6 +233,13 @@ def expect_work_package_in_sprint(work_package, sprint) end end + def expect_work_package_text_in_sprint(work_package, sprint, text) + within_sprint(sprint) do + expect(page) + .to have_selector(work_package_selector(work_package).to_s, text:) + end + end + def expect_work_package_not_in_sprint(work_package, sprint) within_sprint(sprint) do expect(page) From 2fdc804a2fb4604c3fb8bc31702c471d22bd5b9f Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 00:09:00 +0100 Subject: [PATCH 015/105] Normalize Backlogs card row padding Read-only work package cards still render inside Box rows, so their row padding should match draggable card rows. --- frontend/src/global_styles/primer/_overrides.sass | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index cf6a53d0d014..9001a7d8f0c4 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -174,6 +174,9 @@ ul.SegmentedControl, &:active cursor: grabbing +.Box-row:has(> .Box-card) + padding: 0 + .Box-row:is(.Box-row--draggable) --op-backlogs-drop-gap-border-width: 2px --op-backlogs-drop-gap-height: 8px From 22a47b4ccd563629ef163ad868d905cc636c8f37 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 00:56:22 +0100 Subject: [PATCH 016/105] Permit position moves in Backlogs The Backlogs move service still supports absolute position moves, but the controller was filtering the position parameter before it reached the service. Permit the parameter again and cover the controller hop so position-only moves keep working while prev_id precedence remains in the service specs. --- .../backlogs/work_packages_controller.rb | 2 +- .../backlogs/work_packages_controller_spec.rb | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb index e63a8e0e46cc..632ab70e30d4 100644 --- a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb @@ -126,7 +126,7 @@ def load_work_package end def move_params - params.permit(:prev_id, :direction, :list_type, :list_id) + params.permit(:prev_id, :position, :direction, :list_type, :list_id) end # A blank prev_id (drag or menu move to the top of a list) is kept so the diff --git a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb index ac7de68922e5..c1efe348b9e5 100644 --- a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb +++ b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb @@ -109,11 +109,13 @@ def turbo_stream_template(action:) let(:list_type) { nil } let(:list_id) { nil } let(:prev_id) { nil } + let(:position) { nil } let(:all) { nil } let(:direction) { nil } subject do - put :move, params: { project_id:, id:, list_type:, list_id:, prev_id:, all:, direction: }, format: :turbo_stream + put :move, params: { project_id:, id:, list_type:, list_id:, prev_id:, position:, all:, direction: }, + format: :turbo_stream end context "with a Sprint as source" do @@ -229,6 +231,17 @@ def turbo_stream_template(action:) expect(work_package_in_sprint.reload).to have_attributes(sprint_id: nil, backlog_bucket_id: nil, position: 2) end + context "with a position instead of prev_id" do + let(:prev_id) { nil } + let(:position) { "1" } + + it "moves the work_package to the requested position" do + subject + + expect(work_package_in_sprint.reload).to have_attributes(sprint_id: nil, backlog_bucket_id: nil, position: 1) + end + end + include_examples "respecting the all param for inbox pagination" end From 67568586fe97403d50a814b210bc204ab0bab980 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 08:29:08 +0100 Subject: [PATCH 017/105] Fix Backlogs DnD probe assertions Keep the move-request probe installed until the no-request assertion has observed the settled drag result. Also assert against the real Pragmatic DnD target types so the pick-up-and-release probe can catch an accidental item-level target. --- modules/backlogs/spec/support/pages/backlog.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index 02ffa8747ff2..98b9ccf58506 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -418,9 +418,8 @@ def pick_up_and_release_work_package(work_package) pick_up_and_release_backlogs_item(moved_element) return rescue Capybara::Cuprite::ObsoleteNode - next - ensure stop_backlogs_move_request_probe + next end end end @@ -446,7 +445,8 @@ def expect_backlogs_drop_handled_without_item_target JS expect(drop_summary.fetch("handled")).to be(true) - expect(drop_summary.fetch("dropTargetTypes")).not_to include("item") + expect(drop_summary.fetch("dropTargetTypes")).to include("backlog_bucket") + expect(drop_summary.fetch("dropTargetTypes")).not_to include("work_package") end def drag_work_package(moved, before: nil, into: nil) From 4a3c46355a0564e60d41d26c28be76ec670973e7 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 08:31:37 +0100 Subject: [PATCH 018/105] Remove obsolete sortable refresh code Turbo now preserves the Pragmatic DnD marker attributes during morphs, so sortable rows no longer need a controller-level refresh path. Remove the old morph listener, item refresh hook, and fallback hit-testing helper that were only needed for the previous refresh strategy. --- .../dynamic/sortable-lists.controller.spec.ts | 55 ------ .../dynamic/sortable-lists.controller.ts | 18 -- .../sortable-lists/drag-and-drop.spec.ts | 161 +----------------- .../dynamic/sortable-lists/drag-and-drop.ts | 74 +------- .../sortable-lists/item.controller.spec.ts | 31 ---- .../dynamic/sortable-lists/item.controller.ts | 14 -- .../dynamic/sortable-lists/list-dom.spec.ts | 15 -- .../dynamic/sortable-lists/list-dom.ts | 4 - 8 files changed, 2 insertions(+), 370 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 15191f98a1fa..06bbb378e8ee 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -28,8 +28,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */ -import { Controller } from '@hotwired/stimulus'; - import type { autoScrollForElements as autoScrollForElementsFn } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import type { dropTargetForElements as dropTargetForElementsFn, monitorForElements as monitorForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import type { FetchRequest as FetchRequestFn } from '@rails/request.js'; @@ -677,57 +675,4 @@ describe('Sortable lists controller', () => { expect(scrollableCleanup).toHaveBeenCalledOnce(); }); - - describe('Turbo morph coordination', () => { - const refresh = vi.fn(); - - function renderListWithItem() { - class ItemStub extends Controller { - refresh = refresh; - } - ctx.application.register('sortable-lists--item', ItemStub); - - fixture.innerHTML = ` -
                    -
                      -
                    • -
                    -
                    - `; - } - - it('refreshes only the morphed sortable item from a single root-level listener', async () => { - renderListWithItem(); - await ctx.nextFrame(); - - const list = fixture.querySelector('[data-sortable-lists-target="list"]')!; - const item = fixture.querySelector('[data-sortable-lists--item-id-value="1"]')!; - - // A morph on a non-item element under the root is ignored. - list.dispatchEvent(new CustomEvent('turbo:morph-element', { bubbles: true })); - expect(refresh).not.toHaveBeenCalled(); - - // A morph on the item refreshes that item exactly once. - item.dispatchEvent(new CustomEvent('turbo:morph-element', { bubbles: true })); - expect(refresh).toHaveBeenCalledTimes(1); - }); - - it('stops handling morph events once the root disconnects', async () => { - renderListWithItem(); - await ctx.nextFrame(); - - const item = fixture.querySelector('[data-sortable-lists--item-id-value="1"]')!; - - fixture.innerHTML = ''; - await ctx.nextFrame(); - - item.dispatchEvent(new CustomEvent('turbo:morph-element', { bubbles: true })); - expect(refresh).not.toHaveBeenCalled(); - }); - }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 7115f1c9a927..15f1fd99c534 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -51,7 +51,6 @@ import { captureRowPositions, reorderRows, restoreRowPositions, - sortableItemSelector, sortableListSelector, sortableListsMovingAttribute, } from './sortable-lists/list-dom'; @@ -88,7 +87,6 @@ export default class SortableListsController extends Controller { private monitorCleanupFn?:CleanupFn; private listCleanupFns = new Map(); private scrollableCleanupFns = new Map(); - private readonly handleMorphBound = this.handleMorph.bind(this); connect():void { this.monitorCleanupFn = monitorForElements({ @@ -97,13 +95,11 @@ export default class SortableListsController extends Controller { void this.handleDrop(args); }, }); - document.addEventListener('turbo:morph-element', this.handleMorphBound); } disconnect():void { this.monitorCleanupFn?.(); this.monitorCleanupFn = undefined; - document.removeEventListener('turbo:morph-element', this.handleMorphBound); this.listCleanupFns.forEach((cleanup) => cleanup()); this.listCleanupFns.clear(); this.scrollableCleanupFns.forEach((cleanup) => cleanup()); @@ -150,20 +146,6 @@ export default class SortableListsController extends Controller { this.scrollableCleanupFns.delete(element); } - // A Turbo morph can strip the Pragmatic DnD attributes/listeners an item - // controller applied to its row. One root-level listener refreshes the - // affected item, instead of every item registering its own document listener. - private handleMorph(event:Event):void { - const target = event.target; - - if (!(target instanceof HTMLElement) || !target.matches(sortableItemSelector) || !this.element.contains(target)) { - return; - } - - const controller = this.application.getControllerForElementAndIdentifier(target, 'sortable-lists--item'); - (controller as { refresh?:() => void }|null)?.refresh?.(); - } - private get allowedAxis():AutoScrollAllowedAxis { return allowedAxes.has(this.allowedAxisValue) ? this.allowedAxisValue as AutoScrollAllowedAxis : 'vertical'; } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts index 85e026ac8e3f..074442afd5bc 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -28,7 +28,7 @@ import { vi } from 'vitest'; -import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { attachClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { type DragLocationHistory } from '@atlaskit/pragmatic-drag-and-drop/types'; import { acceptsSortableItemType, @@ -36,7 +36,6 @@ import { isSortableItemData, isSortableListData, resolveDropIntent, - resolveFallbackDropTarget, resolveListData, resolvePreviousSortableItemId, sortableItemData, @@ -91,20 +90,6 @@ describe('sortable lists drag and drop helpers', () => { }; } - function stubElementFromPoint(element:Element) { - Object.defineProperty(document, 'elementFromPoint', { - configurable: true, - value: vi.fn(() => element), - }); - } - - function stubElementsFromPoint(elements:Element[]) { - Object.defineProperty(document, 'elementsFromPoint', { - configurable: true, - value: vi.fn(() => elements), - }); - } - afterEach(() => { vi.restoreAllMocks(); document.body.replaceChildren(); @@ -287,148 +272,6 @@ describe('sortable lists drag and drop helpers', () => { }); }); - describe('resolveFallbackDropTarget', () => { - it('resolves an item at the drop coordinates', () => { - const root = document.createElement('div'); - const list = document.createElement('div'); - const row = itemRow('42'); - const item = row.querySelector('article')!; - - list.setAttribute('data-sortable-lists-target', 'list'); - list.setAttribute('data-sortable-lists-list-type', 'backlog_bucket'); - list.setAttribute('data-sortable-lists-list-id', '7'); - list.appendChild(row); - root.appendChild(list); - document.body.appendChild(root); - stubElementFromPoint(item); - vi.spyOn(row, 'getBoundingClientRect').mockReturnValue(rect()); - - const target = resolveFallbackDropTarget({ - input: input({ clientY: 90 }), - root, - }); - - expect(target?.element).toBe(row); - expect(target?.isItem).toBe(true); - expect(target?.data.itemId).toEqual('42'); - expect(extractClosestEdge(target!.data)).toEqual('bottom'); - }); - - it('skips drag overlay elements and resolves the underlying item', () => { - const root = document.createElement('div'); - const list = document.createElement('div'); - const row = itemRow('42'); - const item = row.querySelector('article')!; - const dragOverlay = document.createElement('div'); - - list.setAttribute('data-sortable-lists-target', 'list'); - list.setAttribute('data-sortable-lists-list-type', 'backlog_bucket'); - list.setAttribute('data-sortable-lists-list-id', '7'); - list.appendChild(row); - root.appendChild(list); - document.body.append(root, dragOverlay); - stubElementFromPoint(dragOverlay); - stubElementsFromPoint([dragOverlay, item]); - vi.spyOn(row, 'getBoundingClientRect').mockReturnValue(rect()); - - const target = resolveFallbackDropTarget({ - input: input({ clientY: 90 }), - root, - }); - - expect(target?.element).toBe(row); - expect(target?.isItem).toBe(true); - expect(target?.data.itemId).toEqual('42'); - }); - - it('resolves a list at the drop coordinates when no item is under the pointer', () => { - const root = document.createElement('div'); - const list = document.createElement('div'); - const header = document.createElement('div'); - - list.setAttribute('data-sortable-lists-target', 'list'); - list.setAttribute('data-sortable-lists-list-type', 'backlog_bucket'); - list.setAttribute('data-sortable-lists-list-id', '7'); - list.appendChild(header); - root.appendChild(list); - document.body.appendChild(root); - stubElementFromPoint(header); - - const target = resolveFallbackDropTarget({ - input: input(), - root, - }); - - expect(target?.element).toBe(list); - expect(target?.isItem).toBe(false); - expect(isSortableListData(target!.data)).toBe(true); - expect(target?.data.type).toEqual('backlog_bucket'); - expect(target?.data.listId).toEqual('7'); - }); - - it('resolves a list when the pointer lands on list whitespace above its items', () => { - const root = document.createElement('div'); - const list = document.createElement('div'); - - list.setAttribute('data-sortable-lists-target', 'list'); - list.setAttribute('data-sortable-lists-list-type', 'backlog_bucket'); - list.setAttribute('data-sortable-lists-list-id', '7'); - list.appendChild(itemRow('42')); - root.appendChild(list); - document.body.appendChild(root); - stubElementFromPoint(list); - - const target = resolveFallbackDropTarget({ - input: input(), - root, - }); - - expect(target?.element).toBe(list); - expect(target?.isItem).toBe(false); - expect(isSortableListData(target!.data)).toBe(true); - }); - - it('resolves the containing list instead of the dragged source item', () => { - const root = document.createElement('div'); - const list = document.createElement('div'); - const row = itemRow('42'); - const item = row.querySelector('article')!; - - list.setAttribute('data-sortable-lists-target', 'list'); - list.setAttribute('data-sortable-lists-list-type', 'backlog_bucket'); - list.setAttribute('data-sortable-lists-list-id', '7'); - list.appendChild(row); - root.appendChild(list); - document.body.appendChild(root); - stubElementFromPoint(item); - - const target = resolveFallbackDropTarget({ - input: input(), - root, - sourceElement: row, - }); - - expect(target?.element).toBe(list); - expect(target?.isItem).toBe(false); - expect(isSortableListData(target!.data)).toBe(true); - expect(target?.data.type).toEqual('backlog_bucket'); - expect(target?.data.listId).toEqual('7'); - }); - - it('returns null when the drop coordinates are outside the backlogs root', () => { - const root = document.createElement('div'); - const outside = document.createElement('div'); - - document.body.append(root, outside); - stubElementFromPoint(outside); - - expect(resolveFallbackDropTarget({ - input: input(), - root, - })).toBeNull(); - }); - }); - describe('resolveDropIntent', () => { function dropLocation({ dropTargets = [], @@ -534,7 +377,6 @@ describe('sortable lists drag and drop helpers', () => { list.append(source, target); document.body.appendChild(root); - stubElementFromPoint(target.querySelector('article')!); const intent = resolveDropIntent({ location: dropLocation({ clientY: 90 }), @@ -577,7 +419,6 @@ describe('sortable lists drag and drop helpers', () => { const outside = document.createElement('div'); document.body.append(root, outside); - stubElementFromPoint(outside); const intent = resolveDropIntent({ location: dropLocation(), diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index 270739dc787f..e62a6faa11bc 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -27,16 +27,13 @@ //++ import { - attachClosestEdge, type Edge, extractClosestEdge, } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; -import { type DragLocationHistory, type Input } from '@atlaskit/pragmatic-drag-and-drop/types'; +import { type DragLocationHistory } from '@atlaskit/pragmatic-drag-and-drop/types'; import { - resolveClosestItemElement, resolveItemElement, resolveItemId, - resolveItemType, resolveListAppendPreviousItemId, resolvePreviousItemId, sortableListSelector, @@ -60,12 +57,6 @@ export interface SortableListData extends Record { listId:string|null; } -export interface FallbackDropTarget { - element:HTMLElement; - data:Record; - isItem:boolean; -} - export function isSortableItemData(data:Record):data is SortableItemData { return data[sortableItemDataKey] === true && typeof data.type === 'string' @@ -169,69 +160,6 @@ export function isSourceListTarget({ return sourceElement.closest(sortableListSelector) === targetElement; } -function elementsFromPoint(document:Document, clientX:number, clientY:number):Element[] { - const elements = document.elementsFromPoint?.(clientX, clientY) ?? []; - const element = document.elementFromPoint(clientX, clientY); - - if (element && !elements.includes(element)) { - return [...elements, element]; - } - - return elements; -} - -export function resolveFallbackDropTarget({ - input, - root, - sourceElement, -}:{ - input:Input; - root:HTMLElement; - sourceElement?:HTMLElement; -}):FallbackDropTarget|null { - const elementsAtPoint = elementsFromPoint(root.ownerDocument, input.clientX, input.clientY); - - for (const elementAtPoint of elementsAtPoint) { - if (!(elementAtPoint instanceof HTMLElement) || !root.contains(elementAtPoint)) { - continue; - } - - const item = resolveClosestItemElement(elementAtPoint); - if (item && item !== sourceElement && root.contains(item)) { - const itemId = resolveItemId(item); - - if (itemId) { - return { - element: item, - data: attachClosestEdge(sortableItemData({ itemId, type: resolveItemType(item) }), { - element: item, - input, - allowedEdges: ['top', 'bottom'], - }), - isItem: true, - }; - } - } - - const list = elementAtPoint.closest(sortableListSelector); - if (list && root.contains(list)) { - const listData = resolveListData(list); - - if (!listData) { - continue; - } - - return { - element: list, - data: listData, - isItem: false, - }; - } - } - - return null; -} - export function resolvePreviousSortableItemId({ sourceItemId, targetItem, diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index 7bf467ea5d0e..f95ba7a46e59 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -540,36 +540,5 @@ describe('Sortable lists item controller', () => { expect(preview.querySelector('[data-backlogs--story-target]')).toBeNull(); }); - it('re-registers the row when refreshed after a Turbo morph drops the Pragmatic DnD attributes', async () => { - const { row } = renderBacklogsRow(); - - await ctx.nextFrame(); - expect(row.dataset.dropTargetForElement).toEqual('true'); - - row.removeAttribute('data-drop-target-for-element'); - - const controller = ctx.getController>('sortable-lists--item', row); - controller.refresh(); - - expect(row.dataset.dropTargetForElement).toEqual('true'); - expect(vi.mocked(draggable)).toHaveBeenCalledTimes(2); - expect(vi.mocked(dropTargetForElements)).toHaveBeenCalledTimes(2); - expect(vi.mocked(dropTargetForElements).mock.lastCall?.[0]).toEqual(expect.objectContaining({ - element: row, - })); - }); - - it('skips refreshing while a drag is in flight', async () => { - const { row } = renderBacklogsRow(); - - await ctx.nextFrame(); - row.setAttribute('data-dragging', 'source'); - - const controller = ctx.getController>('sortable-lists--item', row); - controller.refresh(); - - expect(vi.mocked(draggable)).toHaveBeenCalledTimes(1); - expect(vi.mocked(dropTargetForElements)).toHaveBeenCalledTimes(1); - }); }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index d907f88b1a35..88d729d403ed 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -98,20 +98,6 @@ export default class ItemController extends Controller { this.cleanupFn = undefined; } - // Re-run setup after a Turbo morph re-applies the static markup and drops the - // Pragmatic DnD attributes/listeners this controller added. The root - // sortable-lists controller calls this from a single morph listener. Skip - // while an interaction is in flight so an active drag is not torn down - // underneath the user. - refresh():void { - if (this.element.hasAttribute('data-dragging')) { - return; - } - - this.disconnect(); - this.connect(); - } - private registerDraggable():CleanupFn { return draggable({ element: this.element, diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts index 207f199b3d3d..b18ca76c98ac 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts @@ -29,7 +29,6 @@ import { captureRowPositions, reorderRows, - resolveItemType, resolveListAppendPreviousItemId, restoreRowPositions, } from './list-dom'; @@ -66,20 +65,6 @@ describe('sortable lists DOM helpers', () => { .map((element) => element.getAttribute('data-sortable-lists--item-id-value')!); } - describe('resolveItemType', () => { - it('reads the item type Stimulus value', () => { - const item = itemRow('1'); - - item.setAttribute('data-sortable-lists--item-type-value', 'work_package'); - - expect(resolveItemType(item)).toEqual('work_package'); - }); - - it('uses a generic item type when no item type value is present', () => { - expect(resolveItemType(itemRow('1'))).toEqual('item'); - }); - }); - describe('resolveListAppendPreviousItemId', () => { it('returns the last item in a list while skipping the source and truncation marker rows', () => { const list = listElement(); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts index a48bb11b2a6b..fee57bef1eb2 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts @@ -55,10 +55,6 @@ export function resolveItemId(element:Element):string|null { return element.getAttribute('data-sortable-lists--item-id-value'); } -export function resolveItemType(element:Element):string { - return element.getAttribute('data-sortable-lists--item-type-value') ?? 'item'; -} - export function resolveClosestItemElement(element:Element):HTMLElement|null { if (element instanceof HTMLElement && element.matches(sortableItemSelector)) { return element; From 11582a174a901be922c45e68b610127a74f18353 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 09:48:55 +0100 Subject: [PATCH 019/105] Stabilize sortable list request specs Avoid mocking @rails/request.js in the sortable lists controller spec. The browser runner can cache the real module before the per-suite mock is installed, which leaves FetchRequest as a non-spy in the full Chromium job. Stub the fetch boundary instead so the spec still verifies the move requests without relying on module mock ordering. --- .../dynamic/sortable-lists.controller.spec.ts | 123 ++++++++---------- 1 file changed, 51 insertions(+), 72 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 06bbb378e8ee..d59384d588ed 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -30,7 +30,6 @@ import type { autoScrollForElements as autoScrollForElementsFn } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import type { dropTargetForElements as dropTargetForElementsFn, monitorForElements as monitorForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import type { FetchRequest as FetchRequestFn } from '@rails/request.js'; import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; import type SortableListsControllerType from './sortable-lists.controller'; import type { @@ -41,7 +40,6 @@ import type { describe('Sortable lists controller', () => { const flushPromises = () => new Promise((resolve) => setTimeout(resolve)); - let FetchRequest:typeof FetchRequestFn; let dropTargetForElements:typeof dropTargetForElementsFn; let monitorForElements:typeof monitorForElementsFn; let autoScrollForElements:typeof autoScrollForElementsFn; @@ -51,17 +49,11 @@ describe('Sortable lists controller', () => { let ctx:StimulusTestContext; let fixture:HTMLElement; + let fetchMock:ReturnType; let loadingIndicator:HTMLElement; + let renderStreamMessageMock:ReturnType; beforeAll(async () => { - vi.doMock('@rails/request.js', () => ({ - FetchRequest: vi.fn(function FetchRequest() { - return { - perform: vi.fn(() => Promise.resolve({ ok: true })), - }; - }), - })); - vi.doMock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({ draggable: vi.fn(() => vi.fn()), dropTargetForElements: vi.fn(() => vi.fn()), @@ -72,7 +64,6 @@ describe('Sortable lists controller', () => { autoScrollForElements: vi.fn(() => vi.fn()), })); - ({ FetchRequest } = await import('@rails/request.js')); ({ dropTargetForElements, monitorForElements } = await import('@atlaskit/pragmatic-drag-and-drop/element/adapter')); ({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element')); ({ default: SortableListsController } = await import('./sortable-lists.controller')); @@ -229,6 +220,14 @@ describe('Sortable lists controller', () => { beforeEach(async () => { vi.clearAllMocks(); + fetchMock = vi.fn(() => Promise.resolve(new Response('', { status: 200 }))); + renderStreamMessageMock = vi.fn(() => Promise.resolve()); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('Turbo', { + fetch: fetchMock, + renderStreamMessage: renderStreamMessageMock, + }); + loadingIndicator = document.createElement('div'); loadingIndicator.id = 'global-loading-indicator'; loadingIndicator.hidden = true; @@ -245,6 +244,7 @@ describe('Sortable lists controller', () => { afterEach(() => { ctx.dispose(); loadingIndicator.remove(); + vi.unstubAllGlobals(); }); it('does not turn a list-only drop onto the source list into an append move', async () => { @@ -253,7 +253,7 @@ describe('Sortable lists controller', () => { await ctx.nextFrame(); await dropCurrentItemOnList(firstSourceItem, sourceList); - expect(FetchRequest).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('ignores drops that belong to another sortable lists root', async () => { @@ -300,7 +300,7 @@ describe('Sortable lists controller', () => { }, }); - expect(FetchRequest).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('ignores drops whose source type does not match the root accepted type', async () => { @@ -328,7 +328,7 @@ describe('Sortable lists controller', () => { }, }); - expect(FetchRequest).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('appends the item when list-only dropping onto another list', async () => { @@ -337,9 +337,9 @@ describe('Sortable lists controller', () => { await ctx.nextFrame(); await dropCurrentItemOnList(firstSourceItem, targetList); - expect(FetchRequest).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledOnce(); - const options = vi.mocked(FetchRequest).mock.lastCall?.[2] as { body:FormData }; + const options = fetchMock.mock.lastCall?.[1] as { body:FormData }; expect(options.body.get('list_type')).toEqual('sprint'); expect(options.body.get('list_id')).toEqual('1'); @@ -355,10 +355,9 @@ describe('Sortable lists controller', () => { await ctx.nextFrame(); await dropCurrentItemOnList(firstSourceItem, targetList); - expect(FetchRequest).toHaveBeenCalledWith( - 'put', + expect(fetchMock).toHaveBeenCalledWith( '/projects/demo/backlogs/work_packages/1/move', - expect.any(Object), + expect.objectContaining({ method: 'PUT' }), ); }); @@ -371,10 +370,9 @@ describe('Sortable lists controller', () => { await ctx.nextFrame(); await dropCurrentItemOnList(firstSourceItem, targetList); - expect(FetchRequest).toHaveBeenCalledWith( - 'put', + expect(fetchMock).toHaveBeenCalledWith( '/custom/move', - expect.any(Object), + expect.objectContaining({ method: 'PUT' }), ); }); @@ -384,22 +382,19 @@ describe('Sortable lists controller', () => { await ctx.nextFrame(); await dropCurrentItemOnList(firstSourceItem, targetList); - expect(FetchRequest).toHaveBeenCalledWith( - 'put', + expect(fetchMock).toHaveBeenCalledWith( '/move', - expect.any(Object), + expect.objectContaining({ method: 'PUT' }), ); }); it('marks the sortable lists root and global loading indicator while moving an item', async () => { - let resolveMove:(response:{ ok:boolean }) => void; - - vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { - return { - perform: vi.fn(() => new Promise<{ ok:boolean }>((resolve) => { - resolveMove = resolve; - })), - }; + let resolveMove:(response:Response) => void; + + fetchMock.mockImplementationOnce(() => { + return new Promise((resolve) => { + resolveMove = resolve; + }); }); const { root, targetList, firstSourceItem } = renderFixture(); @@ -411,7 +406,7 @@ describe('Sortable lists controller', () => { expect(root.getAttribute('aria-busy')).toEqual('true'); expect(loadingIndicator.hidden).toBe(false); - resolveMove!({ ok: true }); + resolveMove!(new Response('', { status: 200 })); await flushPromises(); expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false); @@ -420,14 +415,12 @@ describe('Sortable lists controller', () => { }); it('rejects new sortable-list drags and drops while a move is pending', async () => { - let resolveMove:(response:{ ok:boolean }) => void; - - vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { - return { - perform: vi.fn(() => new Promise<{ ok:boolean }>((resolve) => { - resolveMove = resolve; - })), - }; + let resolveMove:(response:Response) => void; + + fetchMock.mockImplementationOnce(() => { + return new Promise((resolve) => { + resolveMove = resolve; + }); }); const { targetList, firstSourceItem } = renderFixture(); @@ -445,7 +438,7 @@ describe('Sortable lists controller', () => { source: sourcePayload(firstSourceItem), })).toBe(false); - resolveMove!({ ok: true }); + resolveMove!(new Response('', { status: 200 })); await flushPromises(); }); @@ -454,11 +447,7 @@ describe('Sortable lists controller', () => { const onToast = (event:Event) => toastEvents.push(event as CustomEvent); window.addEventListener('op:toasters:add', onToast); - vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { - return { - perform: vi.fn(() => Promise.reject(new Error('Network failure'))), - }; - }); + fetchMock.mockRejectedValueOnce(new Error('Network failure')); const { root, targetList, firstSourceItem } = renderFixture(); @@ -478,14 +467,12 @@ describe('Sortable lists controller', () => { }); it('moves the dropped row into the target list before the move request resolves', async () => { - let resolveMove:(response:{ ok:boolean }) => void; - - vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { - return { - perform: vi.fn(() => new Promise<{ ok:boolean }>((resolve) => { - resolveMove = resolve; - })), - }; + let resolveMove:(response:Response) => void; + + fetchMock.mockImplementationOnce(() => { + return new Promise((resolve) => { + resolveMove = resolve; + }); }); const { sourceList, targetList, firstSourceItem } = renderFixture(); @@ -497,18 +484,14 @@ describe('Sortable lists controller', () => { expect(itemIds(targetList)).toEqual(['4', '5', '1']); expect(itemIds(sourceList)).toEqual(['2', '3']); - resolveMove!({ ok: true }); + resolveMove!(new Response('', { status: 200 })); await flushPromises(); expect(itemIds(targetList)).toEqual(['4', '5', '1']); }); it('rolls the dropped row back to its original position when the move request fails', async () => { - vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { - return { - perform: vi.fn(() => Promise.reject(new Error('Network failure'))), - }; - }); + fetchMock.mockRejectedValueOnce(new Error('Network failure')); const { sourceList, targetList, firstSourceItem } = renderFixture(); @@ -525,11 +508,10 @@ describe('Sortable lists controller', () => { const onToast = (event:Event) => toastEvents.push(event as CustomEvent); window.addEventListener('op:toasters:add', onToast); - vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { - return { - perform: vi.fn(() => Promise.resolve({ ok: false, statusCode: 422 })), - }; - }); + fetchMock.mockResolvedValueOnce(new Response('', { + headers: { 'Content-Type': 'text/vnd.turbo-stream.html' }, + status: 422, + })); const { sourceList, targetList, firstSourceItem } = renderFixture(); @@ -539,6 +521,7 @@ describe('Sortable lists controller', () => { expect(itemIds(sourceList)).toEqual(['1', '2', '3']); expect(itemIds(targetList)).toEqual(['4', '5']); + expect(renderStreamMessageMock).toHaveBeenCalledOnce(); expect(toastEvents).toHaveLength(0); window.removeEventListener('op:toasters:add', onToast); @@ -549,11 +532,7 @@ describe('Sortable lists controller', () => { const onToast = (event:Event) => toastEvents.push(event as CustomEvent); window.addEventListener('op:toasters:add', onToast); - vi.mocked(FetchRequest).mockImplementationOnce(function FetchRequest() { - return { - perform: vi.fn(() => Promise.resolve({ ok: false, statusCode: 500 })), - }; - }); + fetchMock.mockResolvedValueOnce(new Response('', { status: 500 })); const { sourceList, targetList, firstSourceItem } = renderFixture(); From dab2a0ded8b4629c6b39051a4417aa55592b183f Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 09:55:40 +0100 Subject: [PATCH 020/105] Harden Backlogs DnD edge cases Always restore the Backlogs move-request probe after the pick-up helper runs, including obsolete-node retries and early exits. Also make URI template expansion fully null-safe so a missing URITemplate extension skips template-based moves instead of throwing from toString(). --- .../stimulus/controllers/dynamic/sortable-lists.controller.ts | 2 +- modules/backlogs/spec/support/pages/backlog.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 15f1fd99c534..32e7d31b9926 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -236,7 +236,7 @@ export default class SortableListsController extends Controller { } if (this.hasMoveUrlTemplateValue) { - return URI.expand?.(this.moveUrlTemplateValue, { id: data.itemId }).toString() ?? null; + return URI.expand?.(this.moveUrlTemplateValue, { id: data.itemId })?.toString() ?? null; } return null; diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index 98b9ccf58506..47326d8fe992 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -418,8 +418,9 @@ def pick_up_and_release_work_package(work_package) pick_up_and_release_backlogs_item(moved_element) return rescue Capybara::Cuprite::ObsoleteNode - stop_backlogs_move_request_probe next + ensure + stop_backlogs_move_request_probe end end end From 7055e4e0cd9b53ffc6872c6f04cdebe4a4fdeb31 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 10:08:55 +0100 Subject: [PATCH 021/105] Stabilize 422 rollback spec Wait for the async Turbo Stream failure path before asserting the optimistic rollback. Firefox completes the render-stream and rollback chain one turn later than Chromium in the browser runner. --- .../dynamic/sortable-lists.controller.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index d59384d588ed..aebbaf63d266 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -30,6 +30,7 @@ import type { autoScrollForElements as autoScrollForElementsFn } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import type { dropTargetForElements as dropTargetForElementsFn, monitorForElements as monitorForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { waitFor } from '@testing-library/dom'; import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; import type SortableListsControllerType from './sortable-lists.controller'; import type { @@ -519,9 +520,11 @@ describe('Sortable lists controller', () => { await dropCurrentItemOnList(firstSourceItem, targetList); await flushPromises(); - expect(itemIds(sourceList)).toEqual(['1', '2', '3']); - expect(itemIds(targetList)).toEqual(['4', '5']); - expect(renderStreamMessageMock).toHaveBeenCalledOnce(); + await waitFor(() => { + expect(itemIds(sourceList)).toEqual(['1', '2', '3']); + expect(itemIds(targetList)).toEqual(['4', '5']); + expect(renderStreamMessageMock).toHaveBeenCalledOnce(); + }); expect(toastEvents).toHaveLength(0); window.removeEventListener('op:toasters:add', onToast); From 42f050d8689d1e5f740811528ad316396801d1f2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 11:27:04 +0100 Subject: [PATCH 022/105] Handle Backlogs menu modal cleanup Skip action-menu overlay dismissal once a modal is visible. Menu actions can open the move dialog before the overlay is removed, and Selenium cannot click that stale overlay while the modal owns focus. --- modules/backlogs/spec/support/pages/backlog.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index 47326d8fe992..f36334e5d80a 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -1069,8 +1069,13 @@ def dismiss_menu(menu_owner) selector = "##{overlay_id}" return unless page.has_css?(selector, visible: true, wait: 0) + return if page.has_selector?(:modal, wait: 0) find(selector).click + rescue Selenium::WebDriver::Error::ElementNotInteractableError + # Menu actions can open a modal before the overlay is removed; once the + # modal owns focus the stale overlay is unclickable and dismissal moot. + raise unless page.has_selector?(:modal, wait: 0) end def sprint_names_in_order From 7c43cf3d482db4ad327d1a6e40b0c7a2e3514044 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 11:51:59 +0100 Subject: [PATCH 023/105] Add url-template frontend dependency Provides standalone RFC 6570 URI template expansion, so Stimulus code no longer needs the urijs global with its side-effect URITemplate import. --- frontend/package-lock.json | 10 ++++++++++ frontend/package.json | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c61d7815576a..4d76e0a89e76 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -126,6 +126,7 @@ "turbo_power": "^0.7.1", "typedjson": "^1.5.1", "urijs": "^1.19.11", + "url-template": "^3.1.1", "uuid": "^14.0.0" }, "devDependencies": { @@ -21862,6 +21863,15 @@ "resolved": "https://registry.npmjs.org/url-join/-/url-join-2.0.5.tgz", "integrity": "sha512-c2H1fIgpUdwFRIru9HFno5DT73Ok8hg5oOb5AT3ayIgvCRfxgs2jyt5Slw8kEB7j3QUr6yJmMPDT/odjk7jXow==" }, + "node_modules/url-template": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-3.1.1.tgz", + "integrity": "sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==", + "license": "BSD-3-Clause", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 692117ece089..ac26e96d3585 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,9 +38,9 @@ "@vitest/browser-playwright": "^4.1.7", "@vitest/coverage-v8": "^4.1.7", "@vitest/eslint-plugin": "^1.6.18", + "angular-eslint": "^21.4.0", "browserslist": "^4.28.1", "eslint": "^10.3.0", - "angular-eslint": "^21.4.0", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", @@ -173,6 +173,7 @@ "turbo_power": "^0.7.1", "typedjson": "^1.5.1", "urijs": "^1.19.11", + "url-template": "^3.1.1", "uuid": "^14.0.0" }, "optionalDependencies": { From c63c266d48f5425df408529e63422e46aee9f021 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 11:53:02 +0100 Subject: [PATCH 024/105] Expand sortable move URLs via url-template Swaps out the urijs `URI.expand` expansion with `parseTemplate` from the url-template package. The urijs path relied on a side-effect import that patched the global `URI` object and also an optional chain that silently returned null when that import was missing; a direct import fails loudly at build time instead. --- .../controllers/dynamic/sortable-lists.controller.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 32e7d31b9926..39c04d9f6994 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -37,8 +37,7 @@ import { FetchRequest } from '@rails/request.js'; import { debugLog } from 'core-app/shared/helpers/debug_output'; import { flipMove } from 'core-stimulus/helpers/flip-helper'; import { withLoadingIndicator } from 'core-stimulus/helpers/request-helpers'; -import URI from 'urijs'; -import 'urijs/src/URITemplate'; +import { parseTemplate } from 'url-template'; import { acceptsSortableItemType, buildMoveFormData, @@ -236,7 +235,7 @@ export default class SortableListsController extends Controller { } if (this.hasMoveUrlTemplateValue) { - return URI.expand?.(this.moveUrlTemplateValue, { id: data.itemId })?.toString() ?? null; + return parseTemplate(this.moveUrlTemplateValue).expand({ id: data.itemId }); } return null; From 7b9bcf331d2f2da9b2b735277bf673ff244a5ae7 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 12:04:18 +0100 Subject: [PATCH 025/105] Remove per-item move URL from sortable lists No template ever sets the per-item `moveUrl` Stimulus value, so the root controller's URL template is the only move URL source. Drops the defunct payload field, the resolver precedence branch, and the spec fixtures and absence assertions that referenced the removed attribute. --- .../dynamic/sortable-lists.controller.spec.ts | 70 ++++++------------- .../dynamic/sortable-lists.controller.ts | 6 +- .../sortable-lists/drag-and-drop.spec.ts | 8 --- .../dynamic/sortable-lists/drag-and-drop.ts | 4 -- .../sortable-lists/item.controller.spec.ts | 4 +- .../dynamic/sortable-lists/item.controller.ts | 3 - .../backlogs/bucket_component_spec.rb | 3 - .../backlogs/sprint_component_spec.rb | 3 - ...k_package_card_list_item_component_spec.rb | 2 - 9 files changed, 25 insertions(+), 78 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index aebbaf63d266..e475db278f1b 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -86,26 +86,21 @@ describe('Sortable lists controller', () => { }; } - function itemRow(id:string, { moveUrl = '/move' }:{ moveUrl?:string|null } = {}):HTMLLIElement { + function itemRow(id:string):HTMLLIElement { const row = document.createElement('li'); row.setAttribute('data-sortable-lists--item-id-value', id); row.setAttribute('data-sortable-lists--item-type-value', 'work_package'); - if (moveUrl) { - row.setAttribute('data-sortable-lists--item-move-url-value', moveUrl); - } return row; } function renderFixture({ acceptedType = null, - moveUrlTemplate = null, - itemMoveUrl = '/move', + moveUrlTemplate = '/move/{id}', }:{ acceptedType?:string|null; moveUrlTemplate?:string|null; - itemMoveUrl?:string|null; } = {}) { fixture.innerHTML = `
                    { const [sourceList, targetList] = Array.from(fixture.querySelectorAll('[data-sortable-lists-target="list"]')); const root = fixture.querySelector('[data-controller="sortable-lists"]')!; - sourceList.append(itemRow('1', { moveUrl: itemMoveUrl }), itemRow('2', { moveUrl: itemMoveUrl }), itemRow('3', { moveUrl: itemMoveUrl })); - targetList.append(itemRow('4', { moveUrl: itemMoveUrl }), itemRow('5', { moveUrl: itemMoveUrl })); + sourceList.append(itemRow('1'), itemRow('2'), itemRow('3')); + targetList.append(itemRow('4'), itemRow('5')); return { root, @@ -158,11 +153,7 @@ describe('Sortable lists controller', () => { await monitorOptions?.onDrop?.({ source: sourcePayload( sourceElement, - itemData( - sourceElement.getAttribute('data-sortable-lists--item-id-value')!, - 'work_package', - sourceElement.getAttribute('data-sortable-lists--item-move-url-value') ?? undefined, - ), + itemData(sourceElement.getAttribute('data-sortable-lists--item-id-value')!), ), location: { initial: { @@ -188,8 +179,8 @@ describe('Sortable lists controller', () => { }); } - function itemData(itemId = '1', type = 'work_package', moveUrl?:string) { - return sortableItemData({ itemId, moveUrl, type }); + function itemData(itemId = '1', type = 'work_package') { + return sortableItemData({ itemId, type }); } function sourcePayload(element:HTMLElement, data:Record = itemData()) { @@ -259,18 +250,22 @@ describe('Sortable lists controller', () => { it('ignores drops that belong to another sortable lists root', async () => { fixture.innerHTML = ` -
                    +
                    -
                    +
                      -
                    • +
                    @@ -283,7 +278,7 @@ describe('Sortable lists controller', () => { const secondRootTarget = fixture.querySelector('[data-sortable-lists--item-id-value="11"]')!; await firstRootMonitor?.onDrop?.({ - source: sourcePayload(secondRootSource, itemData('10', 'work_package', '/move-10')), + source: sourcePayload(secondRootSource, itemData('10', 'work_package')), location: { initial: { dropTargets: [], @@ -311,7 +306,7 @@ describe('Sortable lists controller', () => { await ctx.nextFrame(); await vi.mocked(monitorForElements).mock.lastCall?.[0].onDrop?.({ - source: sourcePayload(firstSourceItem, itemData('1', 'meeting_agenda_item', '/move')), + source: sourcePayload(firstSourceItem, itemData('1', 'meeting_agenda_item')), location: { initial: { dropTargets: [], @@ -350,7 +345,6 @@ describe('Sortable lists controller', () => { it('builds the move URL from the controller URI template', async () => { const { targetList, firstSourceItem } = renderFixture({ moveUrlTemplate: '/projects/demo/backlogs/work_packages/{id}/move', - itemMoveUrl: null, }); await ctx.nextFrame(); @@ -362,31 +356,13 @@ describe('Sortable lists controller', () => { ); }); - it('uses the sortable item move URL before the controller URI template', async () => { - const { targetList, firstSourceItem } = renderFixture({ - moveUrlTemplate: '/projects/demo/backlogs/work_packages/{id}/move', - itemMoveUrl: '/custom/move', - }); + it('does nothing when the controller has no move URL template', async () => { + const { targetList, firstSourceItem } = renderFixture({ moveUrlTemplate: null }); await ctx.nextFrame(); await dropCurrentItemOnList(firstSourceItem, targetList); - expect(fetchMock).toHaveBeenCalledWith( - '/custom/move', - expect.objectContaining({ method: 'PUT' }), - ); - }); - - it('falls back to the sortable item move URL while item-specific endpoints still exist', async () => { - const { targetList, firstSourceItem } = renderFixture(); - - await ctx.nextFrame(); - await dropCurrentItemOnList(firstSourceItem, targetList); - - expect(fetchMock).toHaveBeenCalledWith( - '/move', - expect.objectContaining({ method: 'PUT' }), - ); + expect(fetchMock).not.toHaveBeenCalled(); }); it('marks the sortable lists root and global loading indicator while moving an item', async () => { diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 39c04d9f6994..c08b025c4cc0 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -229,11 +229,7 @@ export default class SortableListsController extends Controller { } } - private resolveMoveUrl(data:{ itemId:string; moveUrl?:string }):string|null { - if (data.moveUrl) { - return data.moveUrl; - } - + private resolveMoveUrl(data:{ itemId:string }):string|null { if (this.hasMoveUrlTemplateValue) { return parseTemplate(this.moveUrlTemplateValue).expand({ id: data.itemId }); } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts index 074442afd5bc..aeef4fa026ea 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -131,14 +131,6 @@ describe('sortable lists drag and drop helpers', () => { expect(data.itemId).toEqual('42'); expect(isSortableItemData(data)).toBe(true); }); - - it('includes a move URL when the sortable item has one', () => { - expect(sortableItemData({ type: 'work_package', itemId: '42', moveUrl: '/move' })).toEqual(expect.objectContaining({ - itemId: '42', - moveUrl: '/move', - type: 'work_package', - })); - }); }); describe('acceptsSortableItemType', () => { diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index e62a6faa11bc..12f86cf79d4c 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -48,7 +48,6 @@ export interface SortableItemData extends Record { [sortableItemDataKey]:true; type:string; itemId:string; - moveUrl?:string; } export interface SortableListData extends Record { @@ -75,17 +74,14 @@ export function isSortableListData(data:Record):data is export function sortableItemData({ type, itemId, - moveUrl, }:{ type:string; itemId:string; - moveUrl?:string; }):SortableItemData { return { [sortableItemDataKey]: true, type, itemId, - ...(moveUrl ? { moveUrl } : {}), }; } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index f95ba7a46e59..14a7f0efc1c0 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -401,7 +401,6 @@ describe('Sortable lists item controller', () => { class="Box-row" data-controller="sortable-lists--item" data-test-selector="work-package-${itemId}" - data-sortable-lists--item-move-url-value="/move" data-sortable-lists--item-id-value="${itemId}" data-sortable-lists--item-type-value="work_package" > @@ -473,14 +472,13 @@ describe('Sortable lists item controller', () => { expect(row.hasAttribute('data-dragging')).toBe(false); }); - it('includes the optional move URL in the sortable item data', async () => { + it('builds the sortable item data for the drag payload', async () => { const { article } = renderBacklogsRow(); await ctx.nextFrame(); expect(vi.mocked(draggable).mock.lastCall?.[0].getInitialData?.(draggableArgs(article))).toEqual(expect.objectContaining({ itemId: '123', - moveUrl: '/move', type: 'work_package', })); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index 88d729d403ed..3d710620b933 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -70,12 +70,10 @@ export default class ItemController extends Controller { static values = { id: String, - moveUrl: String, type: { type: String, default: 'item' }, }; declare idValue:string; - declare moveUrlValue:string; declare typeValue:string; declare readonly handleTarget:HTMLElement; @@ -186,7 +184,6 @@ export default class ItemController extends Controller { private getItemData():SortableItemData { return sortableItemData({ itemId: this.idValue, - moveUrl: this.moveUrlValue || undefined, type: this.typeValue, }); } diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index 8177e80592f1..a12e40b97a98 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -233,12 +233,9 @@ def render_component expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package.id}.Box-row--draggable") expect(rendered_component) .to have_no_css(".Box-row#work_package_#{work_package.id}[data-sortable-lists--item-id-value]") - expect(rendered_component) - .to have_no_css(".Box-row#work_package_#{work_package.id}[data-sortable-lists--item-move-url-value]") expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package.id}[draggable='true']") expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-id-value]") expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-target]") - expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-move-url-value]") expect(rendered_component).to have_no_css(".op-work-package-card[draggable='true']") end end diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index ff2dbd47e7b9..8afe8dbf79ba 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -146,12 +146,9 @@ def menu_items expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package1.id}.Box-row--draggable") expect(rendered_component) .to have_no_css(".Box-row#work_package_#{work_package1.id}[data-sortable-lists--item-id-value]") - expect(rendered_component) - .to have_no_css(".Box-row#work_package_#{work_package1.id}[data-sortable-lists--item-move-url-value]") expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package1.id}[draggable='true']") expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-id-value]") expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-target]") - expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-move-url-value]") expect(rendered_component).to have_no_css(".op-work-package-card[draggable='true']") end end diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb index 8caa0f9cb6f7..71e9397532c9 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb @@ -95,7 +95,6 @@ it "does not mark the row as draggable" do expect(item.row_args[:classes]).not_to include("Box-row--draggable") expect(item.row_args[:data]).not_to include(:sortable_lists_prev_item_id) - expect(item.row_args[:data]).not_to include(:sortable_lists__item_move_url_value) expect(item.row_args).not_to include(:draggable) expect(item.row_args).not_to include(:tabindex) end @@ -156,7 +155,6 @@ ".op-work-package-card[data-controller~='backlogs--story']" ) expect(rendered_card).to have_no_css(".op-work-package-card[data-controller~='sortable-lists--item']") - expect(rendered_card).to have_no_css(".op-work-package-card[data-sortable-lists--item-move-url-value]") expect(rendered_card).to have_no_css(".op-work-package-card[draggable='true']") end From efd516f7499bd872a6b483de768cb12913d6f57e Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 12:16:26 +0100 Subject: [PATCH 026/105] Scope Pragmatic DnD morph guard to sortable roots Preserving `data-dragging` globally can leave stale drag state on legacy generic-drag-and-drop (Dragula) screens after a morph, because that same controller sets this attribute as well. Preservation now only applies to elements inside a sortable-lists root. --- .../pragmatic-dnd-morph-attributes.spec.ts | 29 +++++++++++++++---- .../turbo/pragmatic-dnd-morph-attributes.ts | 10 ++++++- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts b/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts index df2be49c8801..de6e0387b5f1 100644 --- a/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts +++ b/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts @@ -37,6 +37,17 @@ describe('Pragmatic DnD morph attribute preservation', () => { }); } + function appendSortableRow():HTMLElement { + const root = document.createElement('div'); + const element = document.createElement('li'); + + root.setAttribute('data-controller', 'sortable-lists'); + root.appendChild(element); + document.body.appendChild(root); + + return element; + } + beforeEach(() => { registerPragmaticDndMorphAttributePreservation(); }); @@ -46,31 +57,39 @@ describe('Pragmatic DnD morph attribute preservation', () => { }); it('preserves Pragmatic DnD drop target markers during Turbo morphs', () => { - const element = document.createElement('li'); + const element = appendSortableRow(); const event = beforeMorphAttributeEvent('data-drop-target-for-element'); element.setAttribute('data-drop-target-for-element', 'true'); - document.body.appendChild(element); element.dispatchEvent(event); expect(event.defaultPrevented).toBe(true); }); it('preserves the active drag marker during Turbo morphs', () => { - const element = document.createElement('li'); + const element = appendSortableRow(); const event = beforeMorphAttributeEvent('data-dragging'); element.setAttribute('data-dragging', 'source'); - document.body.appendChild(element); element.dispatchEvent(event); expect(event.defaultPrevented).toBe(true); }); it('lets unrelated attributes continue morphing', () => { - const element = document.createElement('li'); + const element = appendSortableRow(); const event = beforeMorphAttributeEvent('class'); + element.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + }); + + it('lets marker attributes morph outside sortable lists roots', () => { + const element = document.createElement('li'); + const event = beforeMorphAttributeEvent('data-dragging'); + + element.setAttribute('data-dragging', 'source'); document.body.appendChild(element); element.dispatchEvent(event); diff --git a/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts b/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts index a4d4e131f7a0..fb68559111fe 100644 --- a/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts +++ b/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts @@ -26,6 +26,10 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { sortableListsRootSelector } from '../stimulus/controllers/dynamic/sortable-lists/list-dom'; + +// `data-dragging` is shared with the legacy generic-drag-and-drop controller, +// so preservation is scoped to elements inside a sortable-lists root. const preservedAttributes = new Set([ 'data-dragging', 'data-drop-target-for-element', @@ -40,7 +44,11 @@ export function registerPragmaticDndMorphAttributePreservation():void { registered = true; document.addEventListener('turbo:before-morph-attribute', (event) => { - if (!preservedAttributes.has(event.detail?.attributeName)) { + if (!preservedAttributes.has(event.detail.attributeName)) { + return; + } + + if (!(event.target instanceof Element) || event.target.closest(sortableListsRootSelector) === null) { return; } From 171a8c5d1be6d4cd1bc9545e2d2e2fa62f116835 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 12:30:29 +0100 Subject: [PATCH 027/105] Move Box list styles to BorderBoxListComponent Relocates the `.Box-*` overrides out of the global Primer overrides file into the component stylesheet and removes the redundant padding rule for draggable rows. It replaces the previously hardcoded `.Box-card` padding with variant-aware rules which mirror the upstream `.Box-row` padding of the default, condensed, and spacious Box densities. The detached preview is mounted outside the originating Box, and the density variant class is copied onto the preview container so the card keeps matching padding. --- .../common/border_box_list_component.sass | 76 +++++++++++++++++++ .../src/global_styles/primer/_overrides.sass | 70 ----------------- .../sortable-lists/item.controller.spec.ts | 44 ++++++++++- .../dynamic/sortable-lists/item.controller.ts | 13 ++++ 4 files changed, 131 insertions(+), 72 deletions(-) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index cd8f11ad8724..816f5672b1f7 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -80,3 +80,79 @@ .Box-title min-width: 0 + +.Box-row--focus-gray + &:focus-visible + background-color: var(--bgColor-muted) + +.Box-row--focus-blue + &:focus-visible + background-color: var(--bgColor-accent-muted) + +.Box-row--clickable + cursor: pointer + +.Box-row--draggable + cursor: grab + + &:active + cursor: grabbing + +.Box-row:has(> .Box-card) + padding: 0 + +.Box-row:is(.Box-row--draggable) + --op-backlogs-drop-gap-border-width: 2px + --op-backlogs-drop-gap-height: 8px + + &[data-dragging="source"] + opacity: 0.4 + background-color: var(--bgColor-inset) + box-shadow: inset 0 0 0 var(--borderWidth-default) var(--borderColor-muted) + + &[data-drop-position] + position: relative + + &::before, + &::after + position: absolute + left: 0 + right: 0 + height: var(--op-backlogs-drop-gap-height) + background: linear-gradient( + to bottom, + var(--bgColor-accent-muted) 0, + var(--bgColor-accent-muted) calc(50% - var(--op-backlogs-drop-gap-border-width) / 2), + var(--fgColor-accent) calc(50% - var(--op-backlogs-drop-gap-border-width) / 2), + var(--fgColor-accent) calc(50% + var(--op-backlogs-drop-gap-border-width) / 2), + var(--bgColor-accent-muted) calc(50% + var(--op-backlogs-drop-gap-border-width) / 2), + var(--bgColor-accent-muted) 100% + ) + pointer-events: none + z-index: 1 + + &[data-drop-position="top"]::before + content: '' + top: calc(var(--op-backlogs-drop-gap-height) / -2) + + &[data-drop-position="bottom"]::after + content: '' + bottom: calc(var(--op-backlogs-drop-gap-height) / -2) + +// Reapplies the padding removed from the containing .Box-row, matching the +// .Box-row padding of each Box density variant upstream. +.Box-card + padding: var(--stack-padding-normal) + + &[data-preview] + background-color: var(--bgColor-default) + border: var(--borderWidth-default) solid var(--borderColor-default) + border-radius: var(--borderRadius-medium) + box-shadow: var(--shadow-floating-medium) + opacity: 1 + +.Box--condensed .Box-card + padding: var(--stack-padding-condensed) var(--stack-padding-normal) + +.Box--spacious .Box-card + padding: var(--stack-padding-spacious) diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 9001a7d8f0c4..8b3de3243966 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -157,76 +157,6 @@ ul.SegmentedControl, .ActionListItem-label[class^="__hl_"], .ActionListItem-label[class*=" __hl_"] color: var(--control-fgColor-disabled) !important -.Box-row--focus-gray - &:focus-visible - background-color: var(--bgColor-muted) - -.Box-row--focus-blue - &:focus-visible - background-color: var(--bgColor-accent-muted) - -.Box-row--clickable - cursor: pointer - -.Box-row--draggable - cursor: grab - - &:active - cursor: grabbing - -.Box-row:has(> .Box-card) - padding: 0 - -.Box-row:is(.Box-row--draggable) - --op-backlogs-drop-gap-border-width: 2px - --op-backlogs-drop-gap-height: 8px - - padding: 0 - - &[data-dragging="source"] - opacity: 0.4 - background-color: var(--bgColor-inset) - box-shadow: inset 0 0 0 var(--borderWidth-default) var(--borderColor-muted) - - &[data-drop-position] - position: relative - - &::before, - &::after - position: absolute - left: 0 - right: 0 - height: var(--op-backlogs-drop-gap-height) - background: linear-gradient( - to bottom, - var(--bgColor-accent-muted) 0, - var(--bgColor-accent-muted) calc(50% - var(--op-backlogs-drop-gap-border-width) / 2), - var(--fgColor-accent) calc(50% - var(--op-backlogs-drop-gap-border-width) / 2), - var(--fgColor-accent) calc(50% + var(--op-backlogs-drop-gap-border-width) / 2), - var(--bgColor-accent-muted) calc(50% + var(--op-backlogs-drop-gap-border-width) / 2), - var(--bgColor-accent-muted) 100% - ) - pointer-events: none - z-index: 1 - - &[data-drop-position="top"]::before - content: '' - top: calc(var(--op-backlogs-drop-gap-height) / -2) - - &[data-drop-position="bottom"]::after - content: '' - bottom: calc(var(--op-backlogs-drop-gap-height) / -2) - -.Box-card - padding: var(--stack-padding-condensed) var(--stack-padding-normal) - - &[data-preview] - background-color: var(--bgColor-default) - border: var(--borderWidth-default) solid var(--borderColor-default) - border-radius: var(--borderRadius-medium) - box-shadow: var(--shadow-floating-medium) - opacity: 1 - // Apply the mobile styles as soon as the banner itself is small // Styles are copied from the PVC repo .op-primer-flash diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index 14a7f0efc1c0..0795bb17aeff 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -395,8 +395,8 @@ describe('Sortable lists item controller', () => { ctx.dispose(); }); - function renderBacklogsRow(itemId = '123') { - fixture.innerHTML = ` + function renderBacklogsRow(itemId = '123', { boxClasses = '' } = {}) { + const rowHtml = `
                  • {
                  • `; + fixture.innerHTML = boxClasses ? `
                      ${rowHtml}
                    ` : rowHtml; + return { row: fixture.querySelector('.Box-row')!, article: fixture.querySelector('[data-controller="backlogs--story"]')!, @@ -538,5 +540,43 @@ describe('Sortable lists item controller', () => { expect(preview.querySelector('[data-backlogs--story-target]')).toBeNull(); }); + function generatePreview(article:HTMLElement):HTMLElement { + const previewContainer = document.createElement('div'); + + vi.mocked(draggable).mock.lastCall?.[0].onGenerateDragPreview?.({ + ...dragEventPayload(article), + nativeSetDragImage: vi.fn(), + }); + + const previewOptions = vi.mocked(setCustomNativeDragPreview).mock.lastCall?.[0] as { + render:({ container }:{ container:HTMLElement }) => void; + }; + + previewOptions.render({ container: previewContainer }); + + return previewContainer; + } + + it('copies the Box density variant class onto the preview container', async () => { + const { article } = renderBacklogsRow('123', { boxClasses: 'Box Box--condensed' }); + + await ctx.nextFrame(); + + const container = generatePreview(article); + + expect(container.classList.contains('Box--condensed')).toBe(true); + expect(container.classList.contains('Box--spacious')).toBe(false); + }); + + it('does not add density variant classes for a default-density Box', async () => { + const { article } = renderBacklogsRow('123', { boxClasses: 'Box' }); + + await ctx.nextFrame(); + + const container = generatePreview(article); + + expect(container.classList.contains('Box--condensed')).toBe(false); + expect(container.classList.contains('Box--spacious')).toBe(false); + }); }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index 3d710620b933..4551211331a1 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -65,6 +65,11 @@ const PREVIEW_STRIPPED_ATTRIBUTES = [ 'aria-roledescription', ] as const; +// Box density variant classes copied onto the drag-preview container. The preview is +// mounted outside the originating Box, so variant-scoped card styles (e.g. +// `.Box--condensed .Box-card`) would not apply to it otherwise. +const BOX_DENSITY_VARIANT_CLASSES = ['Box--condensed', 'Box--spacious'] as const; + export default class ItemController extends Controller { static targets = ['handle', 'preview']; @@ -199,6 +204,14 @@ export default class ItemController extends Controller { preview.style.width = `${previewWidth}px`; } + const box = this.element.closest('.Box'); + + BOX_DENSITY_VARIANT_CLASSES.forEach((variant) => { + if (box?.classList.contains(variant)) { + container.classList.add(variant); + } + }); + container.append(preview); } From 30ebba4124acd5bfd21a38602f604ba0a86418f9 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 14:46:28 +0100 Subject: [PATCH 028/105] Remove border from drag preview --- .../open_project/common/border_box_list_component.sass | 1 - 1 file changed, 1 deletion(-) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index 816f5672b1f7..1f134652ede8 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -146,7 +146,6 @@ &[data-preview] background-color: var(--bgColor-default) - border: var(--borderWidth-default) solid var(--borderColor-default) border-radius: var(--borderRadius-medium) box-shadow: var(--shadow-floating-medium) opacity: 1 From 80bb7559eca61fbf84dc338b7dae1e18a85a7d54 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 15:30:00 +0100 Subject: [PATCH 029/105] Apply box-list-item state colors to card rows Moves the cursor modifiers from rows onto the card itself, drops the old row tab stop, and applies the box-list-item state colors. Clicking marks a card selected instantly; aria-current still tracks the open item. --- .../common/border_box_list_component.sass | 104 ++++++++++++------ .../work_package_item.rb | 16 +-- .../dynamic/backlogs/story.controller.spec.ts | 55 ++++++++- .../dynamic/backlogs/story.controller.ts | 32 +++++- .../generic-drag-and-drop.controller.spec.ts | 2 +- .../dynamic/sortable-lists/item.controller.ts | 2 + .../work_package_card_list_item_component.rb | 6 +- .../backlogs/bucket_component_spec.rb | 2 +- .../backlogs/sprint_component_spec.rb | 2 +- ...k_package_card_list_item_component_spec.rb | 25 ++--- .../common/border_box_list_component_spec.rb | 11 +- 11 files changed, 173 insertions(+), 84 deletions(-) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index 1f134652ede8..845b0b4377f0 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -81,55 +81,87 @@ .Box-title min-width: 0 -.Box-row--focus-gray - &:focus-visible - background-color: var(--bgColor-muted) - -.Box-row--focus-blue - &:focus-visible - background-color: var(--bgColor-accent-muted) - -.Box-row--clickable +.Box-card--clickable cursor: pointer -.Box-row--draggable +.Box-card--draggable cursor: grab &:active cursor: grabbing +// Rows wrapping a card delegate their padding to the card and take their +// colors from the box-list-item state variables. .Box-row:has(> .Box-card) padding: 0 + background-color: var(--box-list-item-bg-color) + border-top-color: var(--box-list-item-border-color) + + &:hover + background-color: var(--box-list-item-bg-hover-color) + + &:has(> .Box-card:focus-visible) + background-color: var(--bgColor-muted) -.Box-row:is(.Box-row--draggable) +// Edge borders for row states. The top edge is the row's own top border, the +// bottom edge is the following row's top border; the last row draws its own +// bottom border, overlapping the Box border like the upstream +// Box-body:last-of-type rule. +@mixin op-box-list-item-edge-borders($color) + border-top-color: $color + + & + .Box-row + border-top-color: $color + + &:last-child + margin-bottom: calc(var(--borderWidth-thin) * -1) + border-bottom: var(--borderWidth-thin) solid $color + +// Selected state: applied synchronously on activation by the backlogs--story +// controller (single selection), so the list reacts before the split screen +// has loaded. +.Box-row:has(> .Box-card[data-selected]) + background-color: var(--box-list-item-selected-bg-color) + @include op-box-list-item-edge-borders(var(--box-list-item-selected-border-color)) + + &:hover, + &:has(> .Box-card:focus-visible) + background-color: var(--box-list-item-selected-bg-hover-color) + +// Current state: the card is open in the split screen. aria-current is +// maintained by the backlogs--story controller once the URL reflects the +// details pane; the strong border wins over a simultaneous selected state. +.Box-row:has(> .Box-card[aria-current="true"]) + @include op-box-list-item-edge-borders(var(--box-list-item-pressed-border-color)) + +.Box-row[data-dragging="source"] + opacity: 0.4 + background-color: var(--bgColor-inset) + box-shadow: inset 0 0 0 var(--borderWidth-default) var(--borderColor-muted) + +.Box-row[data-drop-position] --op-backlogs-drop-gap-border-width: 2px --op-backlogs-drop-gap-height: 8px - &[data-dragging="source"] - opacity: 0.4 - background-color: var(--bgColor-inset) - box-shadow: inset 0 0 0 var(--borderWidth-default) var(--borderColor-muted) - - &[data-drop-position] - position: relative - - &::before, - &::after - position: absolute - left: 0 - right: 0 - height: var(--op-backlogs-drop-gap-height) - background: linear-gradient( - to bottom, - var(--bgColor-accent-muted) 0, - var(--bgColor-accent-muted) calc(50% - var(--op-backlogs-drop-gap-border-width) / 2), - var(--fgColor-accent) calc(50% - var(--op-backlogs-drop-gap-border-width) / 2), - var(--fgColor-accent) calc(50% + var(--op-backlogs-drop-gap-border-width) / 2), - var(--bgColor-accent-muted) calc(50% + var(--op-backlogs-drop-gap-border-width) / 2), - var(--bgColor-accent-muted) 100% - ) - pointer-events: none - z-index: 1 + position: relative + + &::before, + &::after + position: absolute + left: 0 + right: 0 + height: var(--op-backlogs-drop-gap-height) + background: linear-gradient( + to bottom, + var(--bgColor-accent-muted) 0, + var(--bgColor-accent-muted) calc(50% - var(--op-backlogs-drop-gap-border-width) / 2), + var(--fgColor-accent) calc(50% - var(--op-backlogs-drop-gap-border-width) / 2), + var(--fgColor-accent) calc(50% + var(--op-backlogs-drop-gap-border-width) / 2), + var(--bgColor-accent-muted) calc(50% + var(--op-backlogs-drop-gap-border-width) / 2), + var(--bgColor-accent-muted) 100% + ) + pointer-events: none + z-index: 1 &[data-drop-position="top"]::before content: '' diff --git a/app/components/open_project/common/border_box_list_component/work_package_item.rb b/app/components/open_project/common/border_box_list_component/work_package_item.rb index 29c443ec04c5..e4d5ccd69a5d 100644 --- a/app/components/open_project/common/border_box_list_component/work_package_item.rb +++ b/app/components/open_project/common/border_box_list_component/work_package_item.rb @@ -78,12 +78,7 @@ def initialize( def row_args row_arguments = @system_arguments.deep_dup row_arguments[:id] ||= dom_id(work_package) - row_arguments[:tabindex] ||= 0 row_arguments[:test_selector] ||= "work-package-#{work_package.id}" - row_arguments[:classes] = class_names( - row_classes, - row_arguments[:classes] - ) row_arguments[:data] = merge_data( { data: row_data }, row_arguments @@ -110,15 +105,14 @@ def card # # @return [ApplicationComponent] def build_card - WorkPackageCardComponent.new(work_package:, classes: "Box-card") + WorkPackageCardComponent.new(work_package:, classes: card_classes) end - def row_classes + def card_classes class_names( - "Box-row--hover-gray", - "Box-row--focus-gray", - "Box-row--clickable", - "Box-row--draggable" => draggable? + "Box-card", + "Box-card--clickable", + "Box-card--draggable" => draggable? ) end diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts index d9cc146b2e66..55fcf43415ad 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts @@ -75,7 +75,6 @@ describe('Backlogs story controller', () => { data-backlogs--story-display-id-value="SP-42" data-backlogs--story-split-url-value="/projects/demo/backlogs/details/SP-42" data-backlogs--story-full-url-value="/work_packages/42" - data-backlogs--story-selected-class="Box-row--blue" tabindex="0" > Story @@ -138,4 +137,58 @@ describe('Backlogs story controller', () => { expect(navigation.openSplitPane).not.toHaveBeenCalled(); expect(navigation.openFullPane).not.toHaveBeenCalled(); }); + + it('marks the card as selected immediately on click, before the pane opens', async () => { + const story = renderStory(); + + await nextFrame(); + story.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(story.hasAttribute('data-selected')).toBe(true); + expect(story.hasAttribute('aria-current')).toBe(false); + expect(navigation.openSplitPane).not.toHaveBeenCalled(); + }); + + it('marks the card as selected when Enter opens the split pane', async () => { + const story = renderStory(); + + await nextFrame(); + keydown(story, 'Enter'); + + expect(story.hasAttribute('data-selected')).toBe(true); + expect(navigation.openSplitPane).toHaveBeenCalledTimes(1); + }); + + it('clears the selection of other stories when a card is selected', async () => { + const story = renderStory(); + const other = document.createElement('article'); + other.setAttribute('data-controller', 'backlogs--story'); + fixture.appendChild(other); + + await nextFrame(); + other.setAttribute('data-selected', ''); + story.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(other.hasAttribute('data-selected')).toBe(false); + expect(story.hasAttribute('data-selected')).toBe(true); + }); + + it('syncs selection and aria-current from the visited URL', async () => { + const story = renderStory(); + + await nextFrame(); + document.dispatchEvent(new CustomEvent('turbo:visit', { + detail: { url: '/projects/demo/backlogs/details/SP-42' }, + })); + + expect(story.getAttribute('aria-current')).toBe('true'); + expect(story.hasAttribute('data-selected')).toBe(true); + + document.dispatchEvent(new CustomEvent('turbo:visit', { + detail: { url: '/projects/demo/backlogs' }, + })); + + expect(story.hasAttribute('aria-current')).toBe(false); + expect(story.hasAttribute('data-selected')).toBe(false); + }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts index 3fe8cbfcbe42..6ea7a272eee5 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts @@ -46,9 +46,6 @@ export default class StoryController extends Controller implements declare splitUrlValue:string; declare fullUrlValue:string; - static classes = ['selected']; - declare readonly selectedClass:string; - private abortController:AbortController|null = null; private clickTimeout:number|null = null; @@ -83,18 +80,38 @@ export default class StoryController extends Controller implements // switch to semantic mode, so accept either form here. if (id !== undefined && (id === this.idValue.toString() || id === this.displayIdValue)) { this.markAsSelected(); + this.markAsCurrent(); } else { this.unmarkAsSelected(); + this.unmarkAsCurrent(); } } + // Selection is applied synchronously on activation, before the split screen + // has loaded, so the list reacts immediately. With single selection any + // previously selected story is cleared right away instead of waiting for + // its own URL sync. markAsSelected():void { - this.element.classList.add(this.selectedClass); - this.element.setAttribute('aria-current', 'true'); + document + .querySelectorAll('[data-controller~="backlogs--story"][data-selected]') + .forEach((other) => { + if (other !== this.element) { + other.removeAttribute('data-selected'); + } + }); + + this.element.setAttribute('data-selected', ''); } unmarkAsSelected():void { - this.element.classList.remove(this.selectedClass); + this.element.removeAttribute('data-selected'); + } + + markAsCurrent():void { + this.element.setAttribute('aria-current', 'true'); + } + + unmarkAsCurrent():void { this.element.removeAttribute('aria-current'); } @@ -120,6 +137,8 @@ export default class StoryController extends Controller implements if (this.clickTimeout !== null) return; + this.markAsSelected(); + this.clickTimeout = window.setTimeout(() => { this.clickTimeout = null; this.openSplitPane(); @@ -154,6 +173,7 @@ export default class StoryController extends Controller implements if (event.shiftKey) { this.openFullPane(); } else { + this.markAsSelected(); this.openSplitPane(); } } diff --git a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.spec.ts index 12c9d180eb73..922891ea873d 100644 --- a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.spec.ts @@ -44,7 +44,7 @@ describe('GenericDragAndDropController', () => { function draggableRow() { const row = document.createElement('li'); - row.className = 'Box-row Box-row--draggable'; + row.className = 'Box-row'; row.tabIndex = 0; row.dataset.draggableId = '42'; row.dataset.draggableType = 'story'; diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index 4551211331a1..d1ac681f094e 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -60,6 +60,8 @@ const PREVIEW_STRIPPED_ATTRIBUTES = [ 'data-dragging', 'data-drop-position', 'data-drop-position-owner', + 'data-selected', + 'aria-current', 'aria-describedby', 'aria-disabled', 'aria-roledescription', diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb index d17fb9b0edad..27a02cdb4a44 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb @@ -60,7 +60,7 @@ def menu_src def card_arguments { - classes: "Box-card", + classes: card_classes, tabindex: 0, data: card_data } @@ -73,8 +73,7 @@ def card_data backlogs__story_id_value: work_package.id, backlogs__story_display_id_value: work_package.display_id, backlogs__story_split_url_value: split_url, - backlogs__story_full_url_value: full_url, - backlogs__story_selected_class: "Box-row--blue" + backlogs__story_full_url_value: full_url } return data unless draggable? @@ -94,7 +93,6 @@ def draggable_data def row_args arguments = super - arguments.delete(:tabindex) arguments[:draggable] = true if draggable? arguments end diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index a12e40b97a98..eea1cf498310 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -230,13 +230,13 @@ def render_component it "does not mark work package rows as draggable" do expect(rendered_component).to have_css(".Box-row#work_package_#{work_package.id}") - expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package.id}.Box-row--draggable") expect(rendered_component) .to have_no_css(".Box-row#work_package_#{work_package.id}[data-sortable-lists--item-id-value]") expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package.id}[draggable='true']") expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-id-value]") expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-target]") expect(rendered_component).to have_no_css(".op-work-package-card[draggable='true']") + expect(rendered_component).to have_no_css(".op-work-package-card.Box-card--draggable") end end end diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index 8afe8dbf79ba..eac24c836d83 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -143,13 +143,13 @@ def menu_items it "does not mark work package rows as draggable" do expect(rendered_component).to have_css(".Box-row#work_package_#{work_package1.id}") - expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package1.id}.Box-row--draggable") expect(rendered_component) .to have_no_css(".Box-row#work_package_#{work_package1.id}[data-sortable-lists--item-id-value]") expect(rendered_component).to have_no_css(".Box-row#work_package_#{work_package1.id}[draggable='true']") expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-id-value]") expect(rendered_component).to have_no_css(".op-work-package-card[data-sortable-lists--item-target]") expect(rendered_component).to have_no_css(".op-work-package-card[draggable='true']") + expect(rendered_component).to have_no_css(".op-work-package-card.Box-card--draggable") end end diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb index 71e9397532c9..608d7e743033 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb @@ -64,12 +64,7 @@ end describe "#row_args" do - it "marks the row as clickable and wires it as the Backlogs draggable item" do - expect(item.row_args[:classes]).to include( - "Box-row--hover-gray", - "Box-row--focus-gray", - "Box-row--clickable" - ) + it "wires the row as the Backlogs draggable item" do expect(item.row_args[:test_selector]).to eq("work-package-#{work_package.id}") expect(item.row_args[:data]).to include( controller: "sortable-lists--item", @@ -80,11 +75,6 @@ expect(item.row_args).not_to include(:tabindex) end - it "marks the row as draggable for users allowed to manage sprint items" do - expect(item.row_args[:classes]).to include("Box-row--draggable") - expect(item.row_args[:data]).not_to include(:sortable_lists_prev_item_id) - end - context "when the user cannot manage sprint items" do let(:role) { create(:project_role, permissions: %i[view_sprints view_work_packages]) } let(:limited_user) { create(:user, member_with_roles: { project => role }) } @@ -93,7 +83,6 @@ end it "does not mark the row as draggable" do - expect(item.row_args[:classes]).not_to include("Box-row--draggable") expect(item.row_args[:data]).not_to include(:sortable_lists_prev_item_id) expect(item.row_args).not_to include(:draggable) expect(item.row_args).not_to include(:tabindex) @@ -102,6 +91,11 @@ it "keeps the card focusable so keyboard users can interact with it" do expect(render_inline(item.card)).to have_css(".op-work-package-card[tabindex='0']") end + + it "does not mark the card as draggable" do + expect(render_inline(item.card)).to have_css(".op-work-package-card.Box-card--clickable") + expect(page).to have_no_css(".Box-card--draggable") + end end end @@ -134,8 +128,10 @@ expect(rendered_card).to have_css(".sr-only", text: "5 story points") end - it "applies the Box-card class to the card" do - expect(rendered_card).to have_css(".op-work-package-card.Box-card") + it "applies the Box-card class and cursor modifiers to the card" do + expect(rendered_card).to have_css( + ".op-work-package-card.Box-card.Box-card--clickable.Box-card--draggable" + ) end it "wires the card as a Backlogs story" do @@ -144,7 +140,6 @@ "[data-backlogs--story-id-value='#{work_package.id}']" \ "[data-backlogs--story-display-id-value='#{work_package.display_id}']" \ "[data-backlogs--story-full-url-value='#{work_package_path(work_package)}']" \ - "[data-backlogs--story-selected-class='Box-row--blue']" \ "[data-sortable-lists--item-target='preview handle']" \ "[tabindex='0']" ) diff --git a/spec/components/open_project/common/border_box_list_component_spec.rb b/spec/components/open_project/common/border_box_list_component_spec.rb index 187b434ae409..0404a86ab637 100644 --- a/spec/components/open_project/common/border_box_list_component_spec.rb +++ b/spec/components/open_project/common/border_box_list_component_spec.rb @@ -522,14 +522,9 @@ def call ) end - it "applies clickable row classes" do - expect(rendered_component).to have_css( - ".Box-row.Box-row--clickable" - ) - end - - it "applies the Box-card class to the work-package card" do - expect(rendered_component).to have_css(".op-work-package-card.Box-card") + it "applies the Box-card class and the clickable modifier to the work-package card" do + expect(rendered_component).to have_css(".op-work-package-card.Box-card.Box-card--clickable") + expect(rendered_component).to have_no_css(".Box-card--draggable") end it "sets the test selector" do From f5038ad6ee48948803d6f03cbeaee2db126427a0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 15:59:23 +0100 Subject: [PATCH 030/105] Show first-row state border in transparent lists The transparent scheme removed the first row's top border completely, so the selected and the current states had no top edge there. A transparent border color keeps the row height stable and gets recolored per state. --- .../common/border_box_list_component.sass | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index 845b0b4377f0..fa9e6f089ced 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -14,8 +14,17 @@ border-bottom: none padding-bottom: var(--stack-padding-condensed) + // A transparent (not absent) border keeps the first row's height stable + // when a state recolors it. These recolors repeat the state rules from + // further down because this selector outweighs them. > ul > .Box-row:first-of-type - border-top: none + border-top-color: transparent + + &:has(> .Box-card[data-selected]) + border-top-color: var(--box-list-item-selected-border-color) + + &:has(> .Box-card[aria-current="true"]) + border-top-color: var(--box-list-item-pressed-border-color) .op-border-box-list --op-border-box-list-header-row-gap: calc(var(--stack-gap-condensed) / 2) From 5da17b84a7eb53cfd16f441aeb63b6e93b66d8ab Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 16:00:37 +0100 Subject: [PATCH 031/105] [AGILE-272] Remove rounded corners on edge rows Upstream rounds the outermost row corners assuming the rows form the box edge. With a header or footer in between, the focus and selection styles showed rounded corners in the middle of the box. https://community.openproject.org/wp/AGILE-272 --- .../common/border_box_list_component.sass | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index fa9e6f089ced..b6b28c5f748b 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -26,6 +26,17 @@ &:has(> .Box-card[aria-current="true"]) border-top-color: var(--box-list-item-pressed-border-color) +// Upstream rounds the outer row corners for boxes whose rows form the box +// edge. With a header or footer between the row and the box edge, a row +// background (hover, focus, selected) would show rounded corners mid-box. +.op-border-box-list:has(> .Box-header) > ul > .Box-row:first-of-type + border-top-left-radius: 0 + border-top-right-radius: 0 + +.op-border-box-list:has(> .Box-footer) > ul > .Box-row:last-of-type + border-bottom-left-radius: 0 + border-bottom-right-radius: 0 + .op-border-box-list --op-border-box-list-header-row-gap: calc(var(--stack-gap-condensed) / 2) From 2aab2f74e07900c7b3e4115c0ada33c8df6a3264 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 16:19:12 +0100 Subject: [PATCH 032/105] Stop Backlogs story spec nav leak Remove fixtures before stopping Stimulus so disconnect clears pending UI click timers before test mocks are restored. --- .../dynamic/backlogs/story.controller.spec.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts index 55fcf43415ad..087bed4e13aa 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts @@ -61,9 +61,10 @@ describe('Backlogs story controller', () => { application.register('backlogs--story', StoryController); }); - afterEach(() => { - application.stop(); + afterEach(async () => { fixture.remove(); + await nextFrame(); + application.stop(); vi.restoreAllMocks(); }); @@ -149,6 +150,25 @@ describe('Backlogs story controller', () => { expect(navigation.openSplitPane).not.toHaveBeenCalled(); }); + it('cancels a pending click activation when the card disconnects', async () => { + const story = renderStory(); + + await nextFrame(); + vi.useFakeTimers(); + + try { + story.dispatchEvent(new MouseEvent('click', { bubbles: true })); + fixture.remove(); + await Promise.resolve(); + await Promise.resolve(); + vi.advanceTimersByTime(250); + + expect(navigation.openSplitPane).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it('marks the card as selected when Enter opens the split pane', async () => { const story = renderStory(); From 9b1486c557504012c0e48b9488fbd5ec284edde8 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 17:11:02 +0100 Subject: [PATCH 033/105] Add useServices mixin for Angular services Stimulus controllers each hand-rolled the `getPluginContext()` await and its disconnect race guard differently. Adds one stimulus-use style mixin that binds the declared services and owns the race handling. Migrates the Backlogs list-refresh controller as the first consumer. --- .../backlogs/list-refresh.controller.ts | 26 +- .../src/stimulus/mixins/use-services.spec.ts | 250 ++++++++++++++++++ frontend/src/stimulus/mixins/use-services.ts | 144 ++++++++++ 3 files changed, 404 insertions(+), 16 deletions(-) create mode 100644 frontend/src/stimulus/mixins/use-services.spec.ts create mode 100644 frontend/src/stimulus/mixins/use-services.ts diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts index ad252b0c96b4..84043cf75ac8 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts @@ -29,6 +29,8 @@ import { Controller } from '@hotwired/stimulus'; import { FrameElement } from '@hotwired/turbo'; import { filter, Subscription } from 'rxjs'; +import type { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; +import { useServices, type ServiceKey } from 'core-stimulus/mixins/use-services'; // The Backlogs lists are server-rendered and updated via Turbo streams on drag // moves, but a work package can also change outside a drag — e.g. edited in the @@ -36,36 +38,28 @@ import { filter, Subscription } from 'rxjs'; // Turbo stream targeting this frame, so subscribe to them and reload the frame // to keep the cards (and sprint point totals) in sync. export default class ListRefreshController extends Controller { - private subscription:Subscription|null = null; - private currentConnectionToken?:symbol; + static services:ServiceKey[] = ['halEvents']; - // eslint-disable-next-line @typescript-eslint/no-misused-promises - async connect() { - const connectionToken = Symbol('backlogs-list-refresh'); - this.currentConnectionToken = connectionToken; + declare halEvents:HalEventsService; - const { services: { halEvents } } = await window.OpenProject.getPluginContext(); + private subscription:Subscription|null = null; - if (!this.isCurrentConnection(connectionToken)) { - return; - } + initialize() { + useServices(this); + } - this.subscription = halEvents + servicesConnected() { + this.subscription = this.halEvents .aggregated$('WorkPackage') .pipe(filter((events) => events.some((event) => event.eventType === 'updated'))) .subscribe(() => { void this.frame?.reload(); }); } disconnect() { - this.currentConnectionToken = undefined; this.subscription?.unsubscribe(); this.subscription = null; } - private isCurrentConnection(connectionToken:symbol):boolean { - return this.element.isConnected && this.currentConnectionToken === connectionToken; - } - private get frame():FrameElement|null { return this.element.closest('turbo-frame'); } diff --git a/frontend/src/stimulus/mixins/use-services.spec.ts b/frontend/src/stimulus/mixins/use-services.spec.ts new file mode 100644 index 000000000000..859da45172e3 --- /dev/null +++ b/frontend/src/stimulus/mixins/use-services.spec.ts @@ -0,0 +1,250 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { waitFor } from '@testing-library/dom'; +import { vi } from 'vitest'; +import { Controller } from '@hotwired/stimulus'; + +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; +import { useServices, type ServiceKey } from './use-services'; + +interface Deferred { + promise:Promise; + resolve:(value:T) => void; +} + +function deferred():Deferred { + let resolve!:(value:T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +class TestController extends Controller { + static services:ServiceKey[] = ['halEvents', 'i18n']; + + declare halEvents:unknown; + declare i18n:unknown; + declare services:Promise>; + declare pluginContext:Promise; + + servicesConnectedCount = 0; + + initialize() { + useServices(this); + } + + servicesConnected() { + this.servicesConnectedCount += 1; + } +} + +class BadServiceController extends Controller { + static services = ['unknownService'] as unknown as ServiceKey[]; + + initialize() { + useServices(this); + } +} + +class PlainController extends Controller { + declare services:Promise>; + + initialize() { + useServices(this); + } +} + +describe('useServices', () => { + let ctx:StimulusTestContext; + let originalOpenProject:typeof window.OpenProject; + let halEvents:{ service:string }; + let i18n:{ service:string }; + let timezone:{ service:string }; + let pluginContext:{ services:Record }; + + function stubPluginContext(getPluginContext:() => Promise) { + window.OpenProject = { getPluginContext } as unknown as typeof window.OpenProject; + } + + beforeEach(async () => { + halEvents = { service: 'halEvents' }; + i18n = { service: 'i18n' }; + timezone = { service: 'timezone' }; + pluginContext = { services: { halEvents, i18n, timezone } }; + + originalOpenProject = window.OpenProject; + stubPluginContext(() => Promise.resolve(pluginContext)); + + ctx = await setupStimulusTest({ + controllers: { + 'use-services-test': TestController, + 'use-services-bad': BadServiceController, + 'use-services-plain': PlainController, + }, + }); + }); + + afterEach(() => { + ctx.dispose(); + window.OpenProject = originalOpenProject; + vi.restoreAllMocks(); + }); + + async function mountController(identifier:string):Promise<{ controller:T, element:HTMLElement }> { + await ctx.mount(`
                    `); + const element = ctx.container.querySelector(`[data-controller="${identifier}"]`)!; + const controller = ctx.getController(identifier, element); + return { controller, element }; + } + + it('binds the declared services on the controller and then calls servicesConnected', async () => { + const { controller } = await mountController('use-services-test'); + + await waitFor(() => { expect(controller.servicesConnectedCount).toBe(1); }); + + expect(controller.halEvents).toBe(halEvents); + expect(controller.i18n).toBe(i18n); + }); + + it('calls servicesConnected again on every reconnect', async () => { + const { controller, element } = await mountController('use-services-test'); + await waitFor(() => { expect(controller.servicesConnectedCount).toBe(1); }); + + element.remove(); + await ctx.nextFrame(); + ctx.container.append(element); + + await waitFor(() => { expect(controller.servicesConnectedCount).toBe(2); }); + }); + + it('does not call servicesConnected when disconnected before the context resolves', async () => { + const context = deferred(); + stubPluginContext(() => context.promise); + + const { controller, element } = await mountController('use-services-test'); + + element.remove(); + await ctx.nextFrame(); + + context.resolve(pluginContext); + await ctx.nextFrame(); + + expect(controller.servicesConnectedCount).toBe(0); + expect(controller.halEvents).toBeUndefined(); + }); + + it('ignores a stale resolution from before a reconnect', async () => { + const first = deferred(); + const second = deferred(); + const getPluginContext = vi.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + stubPluginContext(getPluginContext as () => Promise); + + const { controller, element } = await mountController('use-services-test'); + + element.remove(); + await ctx.nextFrame(); + ctx.container.append(element); + await waitFor(() => { expect(getPluginContext).toHaveBeenCalledTimes(2); }); + + second.resolve(pluginContext); + await waitFor(() => { expect(controller.servicesConnectedCount).toBe(1); }); + + first.resolve(pluginContext); + await ctx.nextFrame(); + + expect(controller.servicesConnectedCount).toBe(1); + }); + + it('exposes a services promise resolving to the declared subset', async () => { + const { controller } = await mountController('use-services-test'); + + const services = await controller.services; + + expect(services).toEqual({ halEvents, i18n }); + }); + + it('never resolves a pending services promise after disconnect', async () => { + const context = deferred(); + stubPluginContext(() => context.promise); + + const { controller, element } = await mountController('use-services-test'); + + const resolved = vi.fn(); + void controller.services.then(resolved); + + element.remove(); + await ctx.nextFrame(); + + context.resolve(pluginContext); + await ctx.nextFrame(); + + expect(resolved).not.toHaveBeenCalled(); + }); + + it('never resolves a services promise obtained after disconnect', async () => { + const context = deferred(); + stubPluginContext(() => context.promise); + + const { controller, element } = await mountController('use-services-test'); + + element.remove(); + await ctx.nextFrame(); + + const resolved = vi.fn(); + void controller.services.then(resolved); + + context.resolve(pluginContext); + await ctx.nextFrame(); + + expect(resolved).not.toHaveBeenCalled(); + }); + + it('exposes a pluginContext promise resolving to the full context', async () => { + const { controller } = await mountController('use-services-test'); + + await expect(controller.pluginContext).resolves.toBe(pluginContext); + }); + + it('reports an error when a declared service does not exist', async () => { + const handleError = vi.fn(); + ctx.application.handleError = handleError; + + await mountController('use-services-bad'); + + await waitFor(() => { expect(handleError).toHaveBeenCalled(); }); + expect((handleError.mock.calls[0][0] as Error).message).toContain('unknownService'); + }); + + it('works without a static services list and without a servicesConnected hook', async () => { + const { controller } = await mountController('use-services-plain'); + + await expect(controller.services).resolves.toEqual({}); + }); +}); diff --git a/frontend/src/stimulus/mixins/use-services.ts b/frontend/src/stimulus/mixins/use-services.ts new file mode 100644 index 000000000000..868df071fb90 --- /dev/null +++ b/frontend/src/stimulus/mixins/use-services.ts @@ -0,0 +1,144 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import type { Controller } from '@hotwired/stimulus'; +import type { OpenProjectPluginContext } from 'core-app/features/plugins/plugin-context'; + +export type ServiceKey = keyof OpenProjectPluginContext['services']; + +export type PickedServices = Pick; + +interface ServiceConsumer { + servicesConnected?:() => void; +} + +/** + * Binds Angular plugin context services to a Stimulus controller, owning all + * the `window.OpenProject.getPluginContext()` boilerplate and its + * disconnect-before-resolve races. + * + * Usage: + * + * export default class ListRefreshController extends Controller { + * static services:ServiceKey[] = ['halEvents']; + * declare halEvents:HalEventsService; + * + * initialize() { + * useServices(this); + * } + * + * // Fires after every connect(), once the context has resolved and the + * // element is still connected. + * servicesConnected() { + * this.subscription = this.halEvents.aggregated$('WorkPackage')... + * } + * } + * + * For use outside `servicesConnected()` (e.g. event handlers), the mixin also + * defines two promise properties on the controller (add matching `declare` + * lines to get typing): + * + * - `this.services` — resolves to the declared subset of context services + * - `this.pluginContext` — resolves to the full `OpenProjectPluginContext`, + * the escape hatch for `classes`, `helpers` and `injector` + * + * Both promises never settle while the controller is disconnected at context + * resolution time — whether obtained before or after the disconnect — so code + * after an `await` cannot act on a dead element. + */ +export function useServices(controller:Controller):void { + const declaredServices = (controller.constructor as unknown as { services?:ServiceKey[] }).services ?? []; + + // Each disconnect invalidates anything still pending from the previous + // connection — replaces the per-controller Symbol-token pattern. + let epoch = 0; + + const guarded = (map:(context:OpenProjectPluginContext) => T):Promise => { + const token = epoch; + return window.OpenProject.getPluginContext().then((context) => { + if (token !== epoch || !controller.element.isConnected) { + return new Promise(() => { + // Disconnected while pending — never settle (see doc block above). + }); + } + return map(context); + }); + }; + + const pickServices = (context:OpenProjectPluginContext):Record => { + const picked:Record = {}; + declaredServices.forEach((key) => { + if (!(key in context.services)) { + throw new Error(`useServices: unknown plugin context service "${key}"`); + } + picked[key] = context.services[key]; + }); + return picked; + }; + + const connectServices = async () => { + const token = epoch; + try { + const context = await window.OpenProject.getPluginContext(); + if (token !== epoch || !controller.element.isConnected) { + return; + } + Object.assign(controller, pickServices(context)); + (controller as ServiceConsumer).servicesConnected?.(); + } catch (error) { + controller.application.handleError( + error as Error, + `Error connecting plugin context services for "${controller.identifier}"`, + {}, + ); + } + }; + + const originalConnect = controller.connect.bind(controller); + const originalDisconnect = controller.disconnect.bind(controller); + + controller.connect = () => { + originalConnect(); + void connectServices(); + }; + + controller.disconnect = () => { + epoch += 1; + originalDisconnect(); + }; + + Object.defineProperty(controller, 'services', { + get: () => guarded(pickServices), + configurable: true, + }); + + Object.defineProperty(controller, 'pluginContext', { + get: () => guarded((context) => context), + configurable: true, + }); +} From dca73e28400b772e6ff94fb0a14f44388bc58e35 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 17:33:07 +0100 Subject: [PATCH 034/105] Handle menu close race in Backlogs dismiss_menu A menu item that opens a modal closes its popover asynchronously, so the overlay can disappear between the visibility check in `dismiss_menu` and the click, raising `Capybara::ElementNotFound`. Rescues it like the existing interactability case, re-raising unless the menu is gone or a modal owns focus. --- modules/backlogs/spec/support/pages/backlog.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index f36334e5d80a..b6db2351ead6 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -1071,11 +1071,13 @@ def dismiss_menu(menu_owner) return unless page.has_css?(selector, visible: true, wait: 0) return if page.has_selector?(:modal, wait: 0) - find(selector).click - rescue Selenium::WebDriver::Error::ElementNotInteractableError - # Menu actions can open a modal before the overlay is removed; once the - # modal owns focus the stale overlay is unclickable and dismissal moot. - raise unless page.has_selector?(:modal, wait: 0) + find(selector, wait: 0).click + rescue Capybara::ElementNotFound, Selenium::WebDriver::Error::ElementNotInteractableError + # Menu actions can close the menu or open a modal between the checks + # above and the click; once the menu is gone or a modal owns focus, + # dismissal is moot. + raise unless page.has_selector?(:modal, wait: 0) || + page.has_no_css?(selector, visible: true, wait: 0) end def sprint_names_in_order From 899edbdd6589240ef722524d6bc3166b91072cbb Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 18:07:36 +0100 Subject: [PATCH 035/105] Factor list_id into Backlogs move target checks Resolves a list move when either `list_type` or `list_id` is given, so a `list_id` passed without its type now fails as an invalid target instead of being silently ignored by the direction handling. --- .../services/backlogs/work_packages/update_service.rb | 6 ++++-- .../backlogs/work_packages/update_service_spec.rb | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/modules/backlogs/app/services/backlogs/work_packages/update_service.rb b/modules/backlogs/app/services/backlogs/work_packages/update_service.rb index d248405ccb12..5457126deb10 100644 --- a/modules/backlogs/app/services/backlogs/work_packages/update_service.rb +++ b/modules/backlogs/app/services/backlogs/work_packages/update_service.rb @@ -51,9 +51,11 @@ def call(direction: nil, list_type: nil, list_id: nil, position: nil, prev_id: n private def resolve_required_attributes(direction:, list_type:, list_id:) - if list_type.present? && direction + list_given = list_type.present? || list_id.present? + + if list_given && direction ServiceResult.failure(message: I18n.t("backlogs.stories.update_service.ambiguous_target")) - elsif list_type.present? + elsif list_given attributes_result_from_list(list_type, list_id) elsif direction attributes_result_from_direction(direction) diff --git a/modules/backlogs/spec/services/backlogs/work_packages/update_service_spec.rb b/modules/backlogs/spec/services/backlogs/work_packages/update_service_spec.rb index 32cd22bcc948..558a99504410 100644 --- a/modules/backlogs/spec/services/backlogs/work_packages/update_service_spec.rb +++ b/modules/backlogs/spec/services/backlogs/work_packages/update_service_spec.rb @@ -71,6 +71,16 @@ "backlogs.stories.update_service.ambiguous_target" end + context "with both list_id and direction" do + it_behaves_like "returns failure without delegating", + { list_id: "42", direction: "highest" }, + "backlogs.stories.update_service.ambiguous_target" + end + + context "with a list_id but no list_type" do + it_behaves_like "returns failure without delegating", { list_id: "42" } + end + context "when list_type is an invalid type with a list_id" do it_behaves_like "returns failure without delegating", { list_type: "unknown", list_id: "42" } end From 39955b723d63b19d743865a3f87f1f0762682094 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 18:10:54 +0100 Subject: [PATCH 036/105] Declare Backlogs controllers on the turbo frame Moves the controller declarations from the inner div onto the frame tags themselves, so the list refresh controller reloads `this.element` instead of breaking out via `closest`. The turbo frame tags carry identical attributes, so that morph refreshes cannot strip the controllers off the live element. --- .../backlogs/list-refresh.controller.spec.ts | 56 +++++++++++-------- .../backlogs/list-refresh.controller.ts | 12 ++-- .../backlogs/backlog/_backlog_list.html.erb | 12 ++-- .../app/views/backlogs/backlog/show.html.erb | 7 ++- 4 files changed, 51 insertions(+), 36 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.spec.ts index f23b38341c7e..803f50013111 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.spec.ts @@ -27,6 +27,7 @@ //++ import { waitFor } from '@testing-library/dom'; +import { Idiomorph } from 'idiomorph'; import { Subject } from 'rxjs'; import { vi, type Mock } from 'vitest'; @@ -83,21 +84,6 @@ describe('Backlogs list-refresh controller', () => { return { frame, reload }; } - async function renderFrameContent() { - await ctx.mount(` - -
                    -
                    - `); - const frame = ctx.container.querySelector('turbo-frame')!; - const reload = vi.fn(); - (frame as unknown as { reload:() => void }).reload = reload; - - await waitFor(() => { expect(aggregated$).toHaveBeenCalledWith('WorkPackage'); }); - - return { frame, reload }; - } - it('subscribes to aggregated WorkPackage events', async () => { await renderFrame(); @@ -112,14 +98,6 @@ describe('Backlogs list-refresh controller', () => { expect(reload).toHaveBeenCalledTimes(1); }); - it('reloads the containing frame when mounted on loaded frame content', async () => { - const { reload } = await renderFrameContent(); - - events$.next([{ eventType: 'updated' }]); - - expect(reload).toHaveBeenCalledTimes(1); - }); - it('ignores aggregated events without an updated entry', async () => { const { reload } = await renderFrame(); @@ -128,6 +106,38 @@ describe('Backlogs list-refresh controller', () => { expect(reload).not.toHaveBeenCalled(); }); + it('keeps its single subscription across a morph refresh', async () => { + await ctx.mount(` + +
                    • one
                    +
                    + `); + const frame = ctx.container.querySelector('turbo-frame')!; + const reload = vi.fn(); + (frame as unknown as { reload:() => void }).reload = reload; + + await waitFor(() => { expect(aggregated$).toHaveBeenCalledWith('WorkPackage'); }); + + // Emulate a Turbo morph refresh: the response frame carries the same + // attributes (they are declared identically on both frame tags) and new + // children, so idiomorph keeps the live element and morphs the content. + Idiomorph.morph( + frame, + ` +
                    • two
                    +
                    `, + { morphStyle: 'outerHTML' }, + ); + await ctx.nextFrame(); + + expect(frame.isConnected).toBe(true); + expect(aggregated$).toHaveBeenCalledTimes(1); + + events$.next([{ eventType: 'updated' }]); + + expect(reload).toHaveBeenCalledTimes(1); + }); + it('stops reloading after the controller disconnects', async () => { const { frame, reload } = await renderFrame(); diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts index 84043cf75ac8..fa2d54f4e01a 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/list-refresh.controller.ts @@ -27,7 +27,7 @@ //++ import { Controller } from '@hotwired/stimulus'; -import { FrameElement } from '@hotwired/turbo'; +import type { FrameElement } from '@hotwired/turbo'; import { filter, Subscription } from 'rxjs'; import type { HalEventsService } from 'core-app/features/hal/services/hal-events.service'; import { useServices, type ServiceKey } from 'core-stimulus/mixins/use-services'; @@ -37,7 +37,9 @@ import { useServices, type ServiceKey } from 'core-stimulus/mixins/use-services' // split pane or by the Angular layer. Those edits emit HAL events rather than a // Turbo stream targeting this frame, so subscribe to them and reload the frame // to keep the cards (and sprint point totals) in sync. -export default class ListRefreshController extends Controller { +// +// Declared on the turbo-frame tag itself, so `this.element` is the frame. +export default class ListRefreshController extends Controller { static services:ServiceKey[] = ['halEvents']; declare halEvents:HalEventsService; @@ -52,15 +54,11 @@ export default class ListRefreshController extends Controller { this.subscription = this.halEvents .aggregated$('WorkPackage') .pipe(filter((events) => events.some((event) => event.eventType === 'updated'))) - .subscribe(() => { void this.frame?.reload(); }); + .subscribe(() => { void this.element.reload(); }); } disconnect() { this.subscription?.unsubscribe(); this.subscription = null; } - - private get frame():FrameElement|null { - return this.element.closest('turbo-frame'); - } } diff --git a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb index fa58da767624..c34fe48f31f2 100644 --- a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb @@ -29,11 +29,13 @@ See COPYRIGHT and LICENSE files for more details. <%= turbo_frame_tag "backlogs_container", refresh: :morph, - class: "op-backlogs-page" do %> -
                    + class: "op-backlogs-page", + data: { + controller: "backlogs--list-refresh sortable-lists", + sortable_lists_accepted_type_value: "work_package", + sortable_lists_move_url_template_value: backlogs_move_url_template(@project) + } do %> +
                    <%= render Backlogs::BacklogComponent.new( diff --git a/modules/backlogs/app/views/backlogs/backlog/show.html.erb b/modules/backlogs/app/views/backlogs/backlog/show.html.erb index 7be34c05bf77..9e185cfa9ce9 100644 --- a/modules/backlogs/app/views/backlogs/backlog/show.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/show.html.erb @@ -46,7 +46,12 @@ See COPYRIGHT and LICENSE files for more details. <%= turbo_frame_tag :backlogs_container, refresh: :morph, src: project_backlogs_backlog_path(@project, all: show_all_backlog), - class: "op-backlogs-page" %> + class: "op-backlogs-page", + data: { + controller: "backlogs--list-refresh sortable-lists", + sortable_lists_accepted_type_value: "work_package", + sortable_lists_move_url_template_value: backlogs_move_url_template(@project) + } %> <% end %> <% content_for :content_body_right do %> From 56af6d66d0963aa12ee19844408b3d9d211897ff Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 10 Jun 2026 18:26:15 +0100 Subject: [PATCH 037/105] Preserve grab offset on Backlogs drag preview Custom preview snapped its top-left to the pointer; preserveOffsetOnSource keeps the grab point like the native preview. --- .../sortable-lists/item.controller.spec.ts | 45 ++++++++++++++++++- .../dynamic/sortable-lists/item.controller.ts | 7 ++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index 0795bb17aeff..5aba932dbb7e 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -116,7 +116,7 @@ describe('Sortable lists item controller', () => { function dragEventPayload(element = document.createElement('article')) { return { - location: {} as never, + location: { current: { input: { clientX: 0, clientY: 0 } } } as never, source: { data: {}, dragHandle: null, @@ -540,6 +540,49 @@ describe('Sortable lists item controller', () => { expect(preview.querySelector('[data-backlogs--story-target]')).toBeNull(); }); + it('offsets the preview so the pointer keeps its grab position on the card', async () => { + const { article } = renderBacklogsRow(); + + vi.spyOn(article, 'getBoundingClientRect').mockReturnValue({ + x: 100, + y: 200, + top: 200, + left: 100, + right: 420, + bottom: 264, + width: 320, + height: 64, + toJSON: vi.fn(), + }); + + await ctx.nextFrame(); + + vi.mocked(draggable).mock.lastCall?.[0].onGenerateDragPreview?.({ + ...dragEventPayload(article), + location: { current: { input: { clientX: 140, clientY: 230 } } } as never, + nativeSetDragImage: vi.fn(), + }); + + const previewOptions = vi.mocked(setCustomNativeDragPreview).mock.lastCall?.[0] as { + getOffset:(args:{ container:HTMLElement }) => { x:number; y:number }; + }; + const container = document.createElement('div'); + + vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 320, + bottom: 64, + width: 320, + height: 64, + toJSON: vi.fn(), + }); + + expect(previewOptions.getOffset({ container })).toEqual({ x: 40, y: 30 }); + }); + function generatePreview(article:HTMLElement):HTMLElement { const previewContainer = document.createElement('div'); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index d1ac681f094e..39fbed5437d3 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -33,6 +33,7 @@ import { } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source'; import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; import { Controller } from '@hotwired/stimulus'; @@ -118,13 +119,17 @@ export default class ItemController extends Controller { this.clearDropIndicator(); this.element.removeAttribute('data-dragging'); }, - onGenerateDragPreview: ({ nativeSetDragImage }) => { + onGenerateDragPreview: ({ location, nativeSetDragImage }) => { if (!this.hasPreviewTarget) { return; } setCustomNativeDragPreview({ nativeSetDragImage, + getOffset: preserveOffsetOnSource({ + element: this.previewTarget, + input: location.current.input, + }), render: ({ container }) => this.renderPreview(container), }); }, From d80e0634e00e582b075c40f10bbed149df666235 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 11 Jun 2026 18:20:08 +0100 Subject: [PATCH 038/105] Use list target data for drops Resolves the target list from the registered drop target data instead of searching the DOM after a drop. --- .../dynamic/sortable-lists.controller.ts | 10 ++-- .../sortable-lists/drag-and-drop.spec.ts | 55 ++++++++++++++----- .../dynamic/sortable-lists/drag-and-drop.ts | 35 +++++------- .../dynamic/sortable-lists/list-dom.ts | 2 + 4 files changed, 63 insertions(+), 39 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index c08b025c4cc0..912c45420b27 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -43,14 +43,13 @@ import { buildMoveFormData, isSortableItemData, resolveDropIntent, - resolveListData, + resolveListTargetData, type SortableListData, } from './sortable-lists/drag-and-drop'; import { captureRowPositions, reorderRows, restoreRowPositions, - sortableListSelector, sortableListsMovingAttribute, } from './sortable-lists/list-dom'; @@ -106,7 +105,7 @@ export default class SortableListsController extends Controller { } listTargetConnected(element:HTMLElement):void { - const listData = resolveListData(element); + const listData = resolveListTargetData(element); if (!listData) { return; } @@ -198,8 +197,7 @@ export default class SortableListsController extends Controller { } const sourceRow = source.element.closest('li'); - const listElement = intent.targetElement.closest(sortableListSelector); - if (!(sourceRow instanceof HTMLElement) || !(listElement instanceof HTMLElement)) { + if (!(sourceRow instanceof HTMLElement)) { return; } @@ -208,7 +206,7 @@ export default class SortableListsController extends Controller { // back to where it started. const rows = [sourceRow]; const rollback = captureRowPositions(rows); - reorderRows({ rows, list: listElement, previousItemId: intent.previousItemId }); + reorderRows({ rows, list: intent.listElement, previousItemId: intent.previousItemId }); const result = await this.moveItem({ listData: intent.listData, diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts index aeef4fa026ea..f2a5e6d67164 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -36,7 +36,7 @@ import { isSortableItemData, isSortableListData, resolveDropIntent, - resolveListData, + resolveListTargetData, resolvePreviousSortableItemId, sortableItemData, sortableListData, @@ -240,27 +240,26 @@ describe('sortable lists drag and drop helpers', () => { }); }); - describe('resolveListData', () => { - it('reads the nearest list type and id', () => { + describe('resolveListTargetData', () => { + it('reads the list type and id from the list element', () => { const list = document.createElement('ul'); - const row = itemRow('1'); - const item = row.querySelector('article')!; - list.setAttribute('data-sortable-lists-target', 'list'); list.setAttribute('data-sortable-lists-list-type', 'sprint'); list.setAttribute('data-sortable-lists-list-id', '12'); - list.appendChild(row); - expect(resolveListData(item)).toEqual(expect.objectContaining({ type: 'sprint', listId: '12' })); + expect(resolveListTargetData(list)).toEqual(expect.objectContaining({ type: 'sprint', listId: '12' })); }); it('uses null as the list id for lists without an id', () => { const list = document.createElement('ul'); - list.setAttribute('data-sortable-lists-target', 'list'); list.setAttribute('data-sortable-lists-list-type', 'inbox'); - expect(resolveListData(list)).toEqual(expect.objectContaining({ type: 'inbox', listId: null })); + expect(resolveListTargetData(list)).toEqual(expect.objectContaining({ type: 'inbox', listId: null })); + }); + + it('returns null for elements without a list type', () => { + expect(resolveListTargetData(document.createElement('ul'))).toBeNull(); }); }); @@ -307,17 +306,47 @@ describe('sortable lists drag and drop helpers', () => { }); const intent = resolveDropIntent({ - location: dropLocation({ dropTargets: [{ data, element: target }] }), + location: dropLocation({ + dropTargets: [ + { data, element: target }, + { data: sortableListData({ type: 'backlog_bucket', listId: '7' }), element: list }, + ], + }), root, sourceElement: source, sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), }); - expect(intent?.targetElement).toBe(target); + expect(intent?.listElement).toBe(list); expect(intent?.listData).toEqual(expect.objectContaining({ type: 'backlog_bucket', listId: '7' })); expect(intent?.previousItemId).toEqual('2'); }); + it('returns null when an item drop has no list target data', () => { + const { root, list } = buildList(); + const source = itemRow('1'); + const target = itemRow('2'); + + list.append(source, target); + document.body.appendChild(root); + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect()); + + const data = attachClosestEdge(sortableItemData({ type: 'work_package', itemId: '2' }), { + element: target, + input: input({ clientY: 90 }), + allowedEdges: ['top', 'bottom'], + }); + + const intent = resolveDropIntent({ + location: dropLocation({ dropTargets: [{ data, element: target }] }), + root, + sourceElement: source, + sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), + }); + + expect(intent).toBeNull(); + }); + it('appends to the list when the drop target is the list itself', () => { const { root, list } = buildList(); const sourceList = document.createElement('ul'); @@ -339,7 +368,7 @@ describe('sortable lists drag and drop helpers', () => { sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), }); - expect(intent?.targetElement).toBe(list); + expect(intent?.listElement).toBe(list); expect(intent?.listData).toEqual(expect.objectContaining({ type: 'backlog_bucket', listId: '7' })); expect(intent?.previousItemId).toEqual('5'); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index 12f86cf79d4c..aefb25537250 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -36,7 +36,9 @@ import { resolveItemId, resolveListAppendPreviousItemId, resolvePreviousItemId, + sortableListIdAttribute, sortableListSelector, + sortableListTypeAttribute, } from './list-dom'; // The Pragmatic DnD payloads exchanged between the sortable-lists root and @@ -117,14 +119,8 @@ export function buildMoveFormData({ return data; } -export function resolveListData(element:Element):SortableListData|null { - const list = element.closest(sortableListSelector); - - if (!list) { - return null; - } - - const type = list.getAttribute('data-sortable-lists-list-type'); +export function resolveListTargetData(element:Element):SortableListData|null { + const type = element.getAttribute(sortableListTypeAttribute); if (!type) { return null; @@ -132,7 +128,7 @@ export function resolveListData(element:Element):SortableListData|null { return sortableListData({ type, - listId: list.getAttribute('data-sortable-lists-list-id'), + listId: element.getAttribute(sortableListIdAttribute), }); } @@ -188,13 +184,13 @@ export function resolvePreviousSortableItemId({ } export interface DropIntent { - targetElement:HTMLElement; + listElement:HTMLElement; listData:SortableListData; previousItemId:string|null; } -// Resolve where a dropped item should land: the element under the drop, the -// list it belongs to and the item the dropped one should be inserted after. +// Resolve where a dropped item should land: the list it was dropped into and +// the item the dropped one should be inserted after. // Returns null when the drop does not amount to a move (outside the root, no // list metadata, or dropped back onto its own list without a target item). export function resolveDropIntent({ @@ -214,18 +210,17 @@ export function resolveDropIntent({ const targetList = location.current.dropTargets.find(({ data, element }) => ( isSortableListData(data) && element instanceof HTMLElement && root.contains(element) )); - const targetElement = targetItem?.element ?? targetList?.element; - - if (!(targetElement instanceof HTMLElement)) { + if (!(targetList?.element instanceof HTMLElement)) { return null; } - const listData = resolveListData(targetElement); - if (!listData) { + const listElement = targetList.element; + const listData = targetList.data; + if (!isSortableListData(listData)) { return null; } - if (!targetItem && isSourceListTarget({ sourceElement, targetElement })) { + if (!targetItem && isSourceListTarget({ sourceElement, targetElement: listElement })) { return null; } @@ -237,8 +232,8 @@ export function resolveDropIntent({ }) : resolveListAppendPreviousItemId({ sourceItemId: sourceData.itemId, - list: targetElement, + list: listElement, }); - return { targetElement, listData, previousItemId }; + return { listElement, listData, previousItemId }; } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts index fee57bef1eb2..7edb4482e2b1 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts @@ -38,6 +38,8 @@ export const sortableListsMovingAttribute = 'data-sortable-lists-moving'; export const sortableListsRootSelector = '[data-controller~="sortable-lists"]'; export const sortableItemSelector = '[data-sortable-lists--item-id-value]'; export const sortableListSelector = '[data-sortable-lists-target~="list"]'; +export const sortableListTypeAttribute = 'data-sortable-lists-list-type'; +export const sortableListIdAttribute = 'data-sortable-lists-list-id'; export const sortablePreviousItemIdAttribute = 'data-sortable-lists-prev-item-id'; // Rows can sit directly under the list element or inside a nested
                      . From e8b14ee779ef454514ee4a2a9369f7c52d62b851 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 11 Jun 2026 18:57:26 +0100 Subject: [PATCH 039/105] Remove redundant type check on drop The list drop target's `canDrop` already rejects drag sources that carry a mismatched item type, and resolving a drop intent requires the target, so the re-check on drop could never reject anything. --- .../dynamic/sortable-lists.controller.spec.ts | 28 ------------------- .../dynamic/sortable-lists.controller.ts | 8 +----- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index e475db278f1b..76f20ecc8599 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -299,34 +299,6 @@ describe('Sortable lists controller', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('ignores drops whose source type does not match the root accepted type', async () => { - const { targetList, firstSourceItem } = renderFixture({ acceptedType: 'work_package' }); - const targetItem = targetList.querySelector('[data-sortable-lists--item-id-value="4"]')!; - - await ctx.nextFrame(); - - await vi.mocked(monitorForElements).mock.lastCall?.[0].onDrop?.({ - source: sourcePayload(firstSourceItem, itemData('1', 'meeting_agenda_item')), - location: { - initial: { - dropTargets: [], - input: input(), - }, - current: { - dropTargets: [ - dropTargetRecord(targetItem, itemData('4', 'work_package')), - ], - input: input(), - }, - previous: { - dropTargets: [], - }, - }, - }); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - it('appends the item when list-only dropping onto another list', async () => { const { targetList, firstSourceItem } = renderFixture(); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 912c45420b27..3280eb487899 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -171,13 +171,7 @@ export default class SortableListsController extends Controller { return; } - if ( - !this.element.contains(source.element) || - !acceptsSortableItemType({ - acceptedType: this.acceptedType, - type: source.data.type, - }) - ) { + if (!this.element.contains(source.element)) { return; } From d488ce151067cc5bc675d2e7548e9c919148d003 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 13 Jun 2026 13:22:03 +0100 Subject: [PATCH 040/105] Move edge-borders mixin to top of sass file Places the mixin definition near the top, ahead of the selectors, for a clearer file structure. Per review preference; definition still precedes its include sites. --- .../common/border_box_list_component.sass | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index b6b28c5f748b..b45f911efb90 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -8,6 +8,20 @@ // See COPYRIGHT and LICENSE files for more details. //++ +// Edge borders for row states. The top edge is the row's own top border, the +// bottom edge is the following row's top border; the last row draws its own +// bottom border, overlapping the Box border like the upstream +// Box-body:last-of-type rule. +@mixin op-box-list-item-edge-borders($color) + border-top-color: $color + + & + .Box-row + border-top-color: $color + + &:last-child + margin-bottom: calc(var(--borderWidth-thin) * -1) + border-bottom: var(--borderWidth-thin) solid $color + .op-border-box-list_transparent > .Box-header background-color: transparent @@ -123,20 +137,6 @@ &:has(> .Box-card:focus-visible) background-color: var(--bgColor-muted) -// Edge borders for row states. The top edge is the row's own top border, the -// bottom edge is the following row's top border; the last row draws its own -// bottom border, overlapping the Box border like the upstream -// Box-body:last-of-type rule. -@mixin op-box-list-item-edge-borders($color) - border-top-color: $color - - & + .Box-row - border-top-color: $color - - &:last-child - margin-bottom: calc(var(--borderWidth-thin) * -1) - border-bottom: var(--borderWidth-thin) solid $color - // Selected state: applied synchronously on activation by the backlogs--story // controller (single selection), so the list reacts before the split screen // has loaded. From 69ad88f1c12f82855a493cddc0282d220136bdb1 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 13 Jun 2026 14:13:28 +0100 Subject: [PATCH 041/105] Fix backlogs move-to-sprint spec The action is now top-level and needs the default dialog wait. --- modules/backlogs/spec/features/backlogs/edit_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backlogs/spec/features/backlogs/edit_spec.rb b/modules/backlogs/spec/features/backlogs/edit_spec.rb index 2f874c02b1e5..613c8ddf3526 100644 --- a/modules/backlogs/spec/features/backlogs/edit_spec.rb +++ b/modules/backlogs/spec/features/backlogs/edit_spec.rb @@ -173,7 +173,7 @@ it "moves a work package to a different sprint" do planning_page.expect_work_package_in_sprint(work_package, first_sprint) - planning_page.click_in_work_package_move_submenu(work_package, "Move to sprint", wait: false) + planning_page.click_in_work_package_menu(work_package, "Move to sprint") within_modal "Move to sprint" do expect(page).to have_no_select("list_id", with_options: [first_sprint.name]) From 7e3453b84c374245f28e19683743157aa667444a Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 13 Jun 2026 14:07:03 +0100 Subject: [PATCH 042/105] Replace move spinner with progress bar The full-screen `withLoadingIndicator` overlay was heavy feedback for a small reorder. Moves now drive the Turbo progress bar through a new `withProgressBar` helper, mark the affected lists with `aria-busy`, and dim only the inner `.Box-list`. The fade honours `prefers-reduced-motion`; the root keeps `data-sortable-lists-moving` as the cross-controller drag guard. --- .../content/modules/_backlogs.sass | 9 +++++ .../dynamic/sortable-lists.controller.spec.ts | 24 ++++++------ .../dynamic/sortable-lists.controller.ts | 8 ++-- .../stimulus/helpers/request-helpers.spec.ts | 37 ++++++++++++++++++- .../src/stimulus/helpers/request-helpers.ts | 9 +++++ 5 files changed, 70 insertions(+), 17 deletions(-) diff --git a/frontend/src/global_styles/content/modules/_backlogs.sass b/frontend/src/global_styles/content/modules/_backlogs.sass index 6bcf06788d62..b9944613e77f 100644 --- a/frontend/src/global_styles/content/modules/_backlogs.sass +++ b/frontend/src/global_styles/content/modules/_backlogs.sass @@ -49,6 +49,15 @@ container-name: backlogsListsContainer container-type: inline-size + [data-sortable-lists-target~="list"][aria-busy="true"] + cursor: progress + + .Box-list + opacity: 0.7 + + @media (prefers-reduced-motion: no-preference) + transition: opacity 0.1s ease-in-out + .op-sprint-planning-container display: flex flex-direction: row diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 76f20ecc8599..327a88ee3876 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -51,7 +51,7 @@ describe('Sortable lists controller', () => { let ctx:StimulusTestContext; let fixture:HTMLElement; let fetchMock:ReturnType; - let loadingIndicator:HTMLElement; + let turboHelpers:typeof import('core-turbo/helpers'); let renderStreamMessageMock:ReturnType; beforeAll(async () => { @@ -69,6 +69,8 @@ describe('Sortable lists controller', () => { ({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element')); ({ default: SortableListsController } = await import('./sortable-lists.controller')); ({ sortableItemData, sortableListData } = await import('./sortable-lists/drag-and-drop')); + + turboHelpers = await import('core-turbo/helpers'); }); function input() { @@ -220,10 +222,8 @@ describe('Sortable lists controller', () => { renderStreamMessage: renderStreamMessageMock, }); - loadingIndicator = document.createElement('div'); - loadingIndicator.id = 'global-loading-indicator'; - loadingIndicator.hidden = true; - document.body.appendChild(loadingIndicator); + vi.spyOn(turboHelpers.TurboHelpers, 'showProgressBar').mockImplementation(() => undefined); + vi.spyOn(turboHelpers.TurboHelpers, 'hideProgressBar').mockImplementation(() => undefined); ctx = await setupStimulusTest({ controllers: { @@ -235,7 +235,7 @@ describe('Sortable lists controller', () => { afterEach(() => { ctx.dispose(); - loadingIndicator.remove(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -337,7 +337,7 @@ describe('Sortable lists controller', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('marks the sortable lists root and global loading indicator while moving an item', async () => { + it('marks the moving state and busy lists and shows the progress bar while moving an item', async () => { let resolveMove:(response:Response) => void; fetchMock.mockImplementationOnce(() => { @@ -352,15 +352,15 @@ describe('Sortable lists controller', () => { await dropCurrentItemOnList(firstSourceItem, targetList); expect(root.dataset.sortableListsMoving).toEqual('true'); - expect(root.getAttribute('aria-busy')).toEqual('true'); - expect(loadingIndicator.hidden).toBe(false); + expect(targetList.getAttribute('aria-busy')).toEqual('true'); + expect(turboHelpers.TurboHelpers.showProgressBar).toHaveBeenCalled(); resolveMove!(new Response('', { status: 200 })); await flushPromises(); expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false); - expect(root.hasAttribute('aria-busy')).toBe(false); - expect(loadingIndicator.hidden).toBe(true); + expect(targetList.hasAttribute('aria-busy')).toBe(false); + expect(turboHelpers.TurboHelpers.hideProgressBar).toHaveBeenCalled(); }); it('rejects new sortable-list drags and drops while a move is pending', async () => { @@ -410,7 +410,7 @@ describe('Sortable lists controller', () => { type: 'error', })); expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false); - expect(loadingIndicator.hidden).toBe(true); + expect(turboHelpers.TurboHelpers.hideProgressBar).toHaveBeenCalled(); window.removeEventListener('op:toasters:add', onToast); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 3280eb487899..b6580364aaab 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -36,7 +36,7 @@ import { Controller } from '@hotwired/stimulus'; import { FetchRequest } from '@rails/request.js'; import { debugLog } from 'core-app/shared/helpers/debug_output'; import { flipMove } from 'core-stimulus/helpers/flip-helper'; -import { withLoadingIndicator } from 'core-stimulus/helpers/request-helpers'; +import { withProgressBar } from 'core-stimulus/helpers/request-helpers'; import { parseTemplate } from 'url-template'; import { acceptsSortableItemType, @@ -253,7 +253,7 @@ export default class SortableListsController extends Controller { this.setMoving(true); try { - const response = await withLoadingIndicator(request.perform()); + const response = await withProgressBar(request.perform()); if (!response.ok) { debugLog(`Failed to move sortable list item: ${response.statusCode}`); @@ -273,10 +273,10 @@ export default class SortableListsController extends Controller { private setMoving(moving:boolean):void { if (moving) { this.element.setAttribute(sortableListsMovingAttribute, 'true'); - this.element.setAttribute('aria-busy', 'true'); + this.listTargets.forEach((list) => list.setAttribute('aria-busy', 'true')); } else { this.element.removeAttribute(sortableListsMovingAttribute); - this.element.removeAttribute('aria-busy'); + this.listTargets.forEach((list) => list.removeAttribute('aria-busy')); } } diff --git a/frontend/src/stimulus/helpers/request-helpers.spec.ts b/frontend/src/stimulus/helpers/request-helpers.spec.ts index 8a39adb5d30b..ce3194bb6c95 100644 --- a/frontend/src/stimulus/helpers/request-helpers.spec.ts +++ b/frontend/src/stimulus/helpers/request-helpers.spec.ts @@ -27,8 +27,9 @@ //++ import type { FetchResponse } from '@rails/request.js'; +import { TurboHelpers } from 'core-turbo/helpers'; -import { withLoadingIndicator } from './request-helpers'; +import { withLoadingIndicator, withProgressBar } from './request-helpers'; describe('withLoadingIndicator', () => { let indicator:HTMLElement|null; @@ -75,3 +76,37 @@ describe('withLoadingIndicator', () => { expect(() => withLoadingIndicator(Promise.resolve({} as FetchResponse))).toThrow(); }); }); + +describe('withProgressBar', () => { + beforeEach(() => { + vi.spyOn(TurboHelpers, 'showProgressBar').mockImplementation(() => undefined); + vi.spyOn(TurboHelpers, 'hideProgressBar').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows the progress bar while the request is pending and hides it on success', async () => { + let resolveRequest!:(response:FetchResponse) => void; + const request = new Promise((resolve) => { resolveRequest = resolve; }); + + const wrapped = withProgressBar(request); + expect(TurboHelpers.showProgressBar).toHaveBeenCalledOnce(); + expect(TurboHelpers.hideProgressBar).not.toHaveBeenCalled(); + + resolveRequest({} as FetchResponse); + await wrapped; + + expect(TurboHelpers.hideProgressBar).toHaveBeenCalledOnce(); + }); + + it('hides the progress bar when the request rejects and propagates the rejection', async () => { + const error = new Error('boom'); + + const wrapped = withProgressBar(Promise.reject(error)); + + await expect(wrapped).rejects.toBe(error); + expect(TurboHelpers.hideProgressBar).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/stimulus/helpers/request-helpers.ts b/frontend/src/stimulus/helpers/request-helpers.ts index 0e4f3f76dac6..ffe9f71b906b 100644 --- a/frontend/src/stimulus/helpers/request-helpers.ts +++ b/frontend/src/stimulus/helpers/request-helpers.ts @@ -30,6 +30,7 @@ import { FetchRequest, FetchResponse, Options } from '@rails/request.js'; import { hideElement, showElement } from 'core-app/shared/helpers/dom-helpers'; +import { TurboHelpers } from 'core-turbo/helpers'; import invariant from 'tiny-invariant'; export function post(url:string|URL, options?:Options) { @@ -47,6 +48,14 @@ export function withLoadingIndicator(request:Promise) { }); } +export function withProgressBar(request:Promise) { + TurboHelpers.showProgressBar(); + + return request.finally(() => { + TurboHelpers.hideProgressBar(); + }); +} + export class FetchRequestError extends Error { constructor(public _errorCode:number, message = 'HTTP Error', options:ErrorOptions = {}) { super(message, options); From 43918a985de3487235e8d4b6b60cd42f38cde674 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 15 Jun 2026 19:35:37 +0200 Subject: [PATCH 043/105] Fix Firefox drag preview offset under pointer Firefox folds a card's box-shadow ink overflow into the native drag-image snapshot, shifting its origin off the border box so the preserved grab offset no longer sits under the pointer. Drops the lift shadow on Firefox only, via the existing `-browser-firefox` body class; other browsers snapshot the border box and keep the elevation. --- .../open_project/common/border_box_list_component.sass | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index b45f911efb90..3682d3ed2798 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -202,6 +202,13 @@ box-shadow: var(--shadow-floating-medium) opacity: 1 + // Firefox folds a card's box-shadow ink overflow into the native drag-image + // snapshot, shifting the snapshot origin off the border box so the grab + // offset no longer lines up under the pointer. Drop the lift shadow on + // Firefox only; other browsers snapshot the border box and are unaffected. + .-browser-firefox & + box-shadow: none + .Box--condensed .Box-card padding: var(--stack-padding-condensed) var(--stack-padding-normal) From a5f3b48d281fcb879084bd5078224540aa24147f Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 15 Jun 2026 21:40:09 +0200 Subject: [PATCH 044/105] Add Reorder and Move to Backlogs glossary The drag-and-drop acceptance criteria distinguish reordering a work package within a Backlogs list from moving it between lists. Names both actions in the glossary so the terms stay consistent across work packages and code. --- modules/backlogs/CONTEXT.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/backlogs/CONTEXT.md b/modules/backlogs/CONTEXT.md index 8ef06996e43e..7fc89ae39b38 100644 --- a/modules/backlogs/CONTEXT.md +++ b/modules/backlogs/CONTEXT.md @@ -20,6 +20,12 @@ _Avoid_: Container, target A user-controlled ordinal value that determines where a work package appears within an ordered list. _Avoid_: Priority order, sort order +**Reorder**: +Changing a work package's Position within a single Backlogs list. + +**Move**: +Relocating a work package from one Backlogs list to another (an Inbox backlog, a Backlog bucket, or a Sprint). Contrast Reorder, which stays within one list. + **Highlighted gap drop indicator**: An accent-tinted gap between work package cards that marks the candidate position for a dragged work package. _Avoid_: Placeholder, drop placeholder From ab20102061d43df59a037ab1311d0daa6f787ee5 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 18 Jun 2026 00:26:07 +0200 Subject: [PATCH 045/105] UX/UI feedback round: Remove progress bar --- .../dynamic/sortable-lists.controller.spec.ts | 11 +---------- .../controllers/dynamic/sortable-lists.controller.ts | 3 +-- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 327a88ee3876..d5b79fa008b6 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -51,7 +51,6 @@ describe('Sortable lists controller', () => { let ctx:StimulusTestContext; let fixture:HTMLElement; let fetchMock:ReturnType; - let turboHelpers:typeof import('core-turbo/helpers'); let renderStreamMessageMock:ReturnType; beforeAll(async () => { @@ -69,8 +68,6 @@ describe('Sortable lists controller', () => { ({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element')); ({ default: SortableListsController } = await import('./sortable-lists.controller')); ({ sortableItemData, sortableListData } = await import('./sortable-lists/drag-and-drop')); - - turboHelpers = await import('core-turbo/helpers'); }); function input() { @@ -222,9 +219,6 @@ describe('Sortable lists controller', () => { renderStreamMessage: renderStreamMessageMock, }); - vi.spyOn(turboHelpers.TurboHelpers, 'showProgressBar').mockImplementation(() => undefined); - vi.spyOn(turboHelpers.TurboHelpers, 'hideProgressBar').mockImplementation(() => undefined); - ctx = await setupStimulusTest({ controllers: { 'sortable-lists': SortableListsController, @@ -337,7 +331,7 @@ describe('Sortable lists controller', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('marks the moving state and busy lists and shows the progress bar while moving an item', async () => { + it('marks the moving state and busy lists while moving an item', async () => { let resolveMove:(response:Response) => void; fetchMock.mockImplementationOnce(() => { @@ -353,14 +347,12 @@ describe('Sortable lists controller', () => { expect(root.dataset.sortableListsMoving).toEqual('true'); expect(targetList.getAttribute('aria-busy')).toEqual('true'); - expect(turboHelpers.TurboHelpers.showProgressBar).toHaveBeenCalled(); resolveMove!(new Response('', { status: 200 })); await flushPromises(); expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false); expect(targetList.hasAttribute('aria-busy')).toBe(false); - expect(turboHelpers.TurboHelpers.hideProgressBar).toHaveBeenCalled(); }); it('rejects new sortable-list drags and drops while a move is pending', async () => { @@ -410,7 +402,6 @@ describe('Sortable lists controller', () => { type: 'error', })); expect(root.hasAttribute('data-sortable-lists-moving')).toBe(false); - expect(turboHelpers.TurboHelpers.hideProgressBar).toHaveBeenCalled(); window.removeEventListener('op:toasters:add', onToast); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index b6580364aaab..8e52d5565b7d 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -36,7 +36,6 @@ import { Controller } from '@hotwired/stimulus'; import { FetchRequest } from '@rails/request.js'; import { debugLog } from 'core-app/shared/helpers/debug_output'; import { flipMove } from 'core-stimulus/helpers/flip-helper'; -import { withProgressBar } from 'core-stimulus/helpers/request-helpers'; import { parseTemplate } from 'url-template'; import { acceptsSortableItemType, @@ -253,7 +252,7 @@ export default class SortableListsController extends Controller { this.setMoving(true); try { - const response = await withProgressBar(request.perform()); + const response = await request.perform(); if (!response.ok) { debugLog(`Failed to move sortable list item: ${response.statusCode}`); From 9d7d9b400a89ab0b4f96c654b480273ee341bdde Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 18 Jun 2026 11:49:05 +0200 Subject: [PATCH 046/105] Bump @types/hotwired__turbo from 8.0.10 to 8.0.11 in /frontend --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b39bfa399515..56916a78d3b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -145,7 +145,7 @@ "@types/dragula": "^3.7.5", "@types/flot": "^0.0.36", "@types/hammerjs": "^2.0.36", - "@types/hotwired__turbo": "^8.0.10", + "@types/hotwired__turbo": "^8.0.11", "@types/jquery": "^4.0.1", "@types/jquery-migrate": "^3.3.3", "@types/lodash": "^4.17.24", @@ -7027,9 +7027,9 @@ } }, "node_modules/@types/hotwired__turbo": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/@types/hotwired__turbo/-/hotwired__turbo-8.0.10.tgz", - "integrity": "sha512-ZnNDmfE2mwvbpmq55ntbTwP82Y4U+g3lcGMTWvS+Vo5LEuO6YQrWVhCmrTae3IH19/nNcHvzbNuroNWEwRylFg==", + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/@types/hotwired__turbo/-/hotwired__turbo-8.0.11.tgz", + "integrity": "sha512-CuI5wwMNDWVd1Gx8XElxMqPyChrAq2sgwE8HsnykRZVhgKALbZbS0fNnc2X6BYmUMjedtB1Y8CfNNI2MN/JW4Q==", "dev": true, "license": "MIT" }, diff --git a/frontend/package.json b/frontend/package.json index d49a3fa4cbb4..3931b0d756f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,7 @@ "@types/dragula": "^3.7.5", "@types/flot": "^0.0.36", "@types/hammerjs": "^2.0.36", - "@types/hotwired__turbo": "^8.0.10", + "@types/hotwired__turbo": "^8.0.11", "@types/jquery": "^4.0.1", "@types/jquery-migrate": "^3.3.3", "@types/lodash": "^4.17.24", From 950cd1e19103236260d532b9ebdbb11cbca616ee Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 23 Jun 2026 13:55:00 +0200 Subject: [PATCH 047/105] Fix backlog DnD filter preservation --- .../dynamic/sortable-lists.controller.spec.ts | 32 +++++++++++++++++++ .../dynamic/sortable-lists.controller.ts | 25 ++++++++++++++- .../features/backlogs/filter_panel_spec.rb | 2 +- .../backlogs/spec/support/pages/backlog.rb | 10 ++++-- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index d5b79fa008b6..b460703f8e2e 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -322,6 +322,38 @@ describe('Sortable lists controller', () => { ); }); + it('adds the current turbo frame query to the move URL', async () => { + fixture.innerHTML = ` + +
                        +
                          +
                          + `; + + const [sourceList, targetList] = Array.from( + fixture.querySelectorAll('[data-sortable-lists-target="list"]'), + ); + sourceList.append(itemRow('1')); + targetList.append(itemRow('4')); + + await ctx.nextFrame(); + await dropCurrentItemOnList( + sourceList.querySelector('[data-sortable-lists--item-id-value="1"]')!, + targetList, + ); + + expect(fetchMock).toHaveBeenCalledWith( + '/projects/demo/backlogs/work_packages/1/move?bucket_ids%5B%5D=1&bucket_ids%5B%5D=inbox&sprint_ids%5B%5D=2', + expect.objectContaining({ method: 'PUT' }), + ); + }); + it('does nothing when the controller has no move URL template', async () => { const { targetList, firstSourceItem } = renderFixture({ moveUrlTemplate: null }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 8e52d5565b7d..e0f593cd488f 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -222,12 +222,35 @@ export default class SortableListsController extends Controller { private resolveMoveUrl(data:{ itemId:string }):string|null { if (this.hasMoveUrlTemplateValue) { - return parseTemplate(this.moveUrlTemplateValue).expand({ id: data.itemId }); + return this.withCurrentFrameQuery( + parseTemplate(this.moveUrlTemplateValue).expand({ id: data.itemId }), + ); } return null; } + private withCurrentFrameQuery(moveUrl:string):string { + const src = this.element.tagName === 'TURBO-FRAME' ? this.element.getAttribute('src') : null; + + if (!src) { + return moveUrl; + } + + const frameParams = new URL(src, window.location.href).searchParams; + if (frameParams.size === 0) { + return moveUrl; + } + + const url = new URL(moveUrl, window.location.href); + const replacedKeys = new Set(frameParams.keys()); + + replacedKeys.forEach((key) => url.searchParams.delete(key)); + frameParams.forEach((value, key) => url.searchParams.append(key, value)); + + return /^[a-z][a-z\d+\-.]*:/i.test(moveUrl) ? url.toString() : `${url.pathname}${url.search}${url.hash}`; + } + private async moveItem({ listData, previousItemId, diff --git a/modules/backlogs/spec/features/backlogs/filter_panel_spec.rb b/modules/backlogs/spec/features/backlogs/filter_panel_spec.rb index 3f8b02319daa..ca37e94246b0 100644 --- a/modules/backlogs/spec/features/backlogs/filter_panel_spec.rb +++ b/modules/backlogs/spec/features/backlogs/filter_panel_spec.rb @@ -225,7 +225,7 @@ def expect_selected_filters_preserved expect_selected_filters_preserved end - it "preserves the filter after drag and drop" do + it "preserves the filter after drag and drop", :selenium do backlogs_page.drag_work_package_to_backlog_inbox(sprint_a_wp) expect_selected_filters_preserved diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index fe21f6d5554f..a9e01bd11540 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -564,9 +564,10 @@ def drag_work_package_to_backlog_inbox(work_package) moved_element = find(draggable_work_package_selector(work_package)) inbox = find(backlog_inbox_selector) target_item = inbox.all("[data-sortable-lists--item-id-value]", minimum: 0).last + target_element = target_item || inbox.find("[data-empty-list-item]") wait_for_backlogs_turbo_stream do - drag_backlogs_item(source: moved_element, target: target_item || inbox, edge: target_item ? :bottom : nil) + drag_backlogs_item(source: moved_element, target: target_element, edge: target_item ? :bottom : nil) end wait_for { work_package.reload.backlog_bucket_id }.to be_nil wait_for { work_package.reload.sprint_id }.to be_nil @@ -772,7 +773,12 @@ def selenium_drag_backlogs_item(source:, target:, edge: nil) .driver .browser .action - .drag_and_drop_by(source.native, target_x - source_x, target_y - source_y) + .move_to(source.native) + .click_and_hold(source.native) + .pause(duration: 0.1) + .move_by(target_x - source_x, target_y - source_y) + .pause(duration: 0.1) + .release .perform # Assert Pragmatic DnD tore down its own honey-pot overlay before we force From ec8f0762e0b2efe9a71c7e1ede4e3fe44e83c9a5 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 23 Jun 2026 19:01:35 +0200 Subject: [PATCH 048/105] Stabilize inbox show all URL expectation --- modules/backlogs/spec/features/inbox_column_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backlogs/spec/features/inbox_column_spec.rb b/modules/backlogs/spec/features/inbox_column_spec.rb index ad1505f51158..7083c12e4adc 100644 --- a/modules/backlogs/spec/features/inbox_column_spec.rb +++ b/modules/backlogs/spec/features/inbox_column_spec.rb @@ -457,7 +457,7 @@ # Expand inbox — URL advances to ?all=true planning_page.click_inbox_show_more - expect(page.current_url).to include("all=true") + expect(page).to have_current_path(project_backlogs_backlog_path(project, all: true)) planning_page.expect_no_inbox_show_more # Drag an inbox item to the sprint From 83310163f83af67875a924dc84d82cf8c86e9769 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:24:45 +0300 Subject: [PATCH 049/105] [AGILE-302] Do not carry the filter state in all the backlog urls https://community.openproject.org/wp/AGILE-302 Simplify preserving the filter params by removing them from all the urls inside the backlog backlogs_container frame. The filter params are only preserved on the frame src and all actions that happen inside the frame should reload the frame by calling reload_frame_via_turbo_stream("backlogs_container"). No further turbo stream components are required. --- .../concerns/op_turbo/component_stream.rb | 4 +++ frontend/src/turbo/setup.ts | 1 + .../work_package_card_list_item_component.rb | 2 +- .../work_package_card_menu_component.html.erb | 8 ++--- .../work_package_card_menu_component.rb | 2 +- .../backlogs/work_packages_controller.rb | 36 +------------------ 6 files changed, 12 insertions(+), 41 deletions(-) diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb index 36138d95bda2..2106576d7c53 100644 --- a/app/controllers/concerns/op_turbo/component_stream.rb +++ b/app/controllers/concerns/op_turbo/component_stream.rb @@ -158,6 +158,10 @@ def reload_page_via_turbo_stream turbo_streams << OpTurbo::StreamComponent.new(action: :reloadPage, target: nil).render_in(view_context) end + def reload_frame_via_turbo_stream(target) + turbo_streams << turbo_stream.turbo_frame_reload(target) + end + # Dispatches a `CustomEvent` on `document` from a turbo stream, letting the # server signal a client-side change without knowing which listeners (if # any) react to it. `detail` is serialized and exposed as the event's detail. diff --git a/frontend/src/turbo/setup.ts b/frontend/src/turbo/setup.ts index df3e35104c82..045390f88102 100644 --- a/frontend/src/turbo/setup.ts +++ b/frontend/src/turbo/setup.ts @@ -60,6 +60,7 @@ applyTurboNavigationPatch(); // Register only the turbo-power stream actions we actually use TurboPower.register('push_state', TurboPower.Actions.push_state, StreamActions); TurboPower.register('turbo_frame_set_src', TurboPower.Actions.turbo_frame_set_src, StreamActions); +TurboPower.register('turbo_frame_reload', TurboPower.Actions.turbo_frame_reload, StreamActions); TurboPower.register('redirect_to', TurboPower.Actions.redirect_to, StreamActions); TurboPower.register('set_dataset_attribute', TurboPower.Actions.set_dataset_attribute, StreamActions); TurboPower.register('set_title', TurboPower.Actions.set_title, StreamActions); diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb index 27a02cdb4a44..de77520576e8 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb @@ -55,7 +55,7 @@ def full_url end def menu_src - url_helpers.menu_project_backlogs_work_package_path(project, work_package, params) + url_helpers.menu_project_backlogs_work_package_path(project, work_package) end def card_arguments diff --git a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb index 19cc71ffef88..b794178e9e98 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb @@ -33,7 +33,7 @@ See COPYRIGHT and LICENSE files for more details. id: dom_target(work_package, :menu, :open_details), tag: :a, label: t(:"js.button_open_details"), - href: project_backlogs_backlog_details_path(project, work_package, backlog_filter_params), + href: project_backlogs_backlog_details_path(project, work_package), content_arguments: { data: { turbo_frame: "content-bodyRight", turbo_action: "advance" } } ) do |item| item.with_leading_visual_icon(icon: :"op-view-split") @@ -75,7 +75,7 @@ See COPYRIGHT and LICENSE files for more details. id: dom_target(work_package, :menu, :move_to_inbox), label: t(".action_menu.move_to_inbox"), tag: :button, - href: move_project_backlogs_work_package_path(project, work_package, backlog_filter_params), + href: move_project_backlogs_work_package_path(project, work_package), form_arguments: { method: :put, inputs: [{ name: "list_type", value: "inbox" }] } ) do |item| item.with_leading_visual_icon(icon: :inbox) @@ -86,7 +86,7 @@ See COPYRIGHT and LICENSE files for more details. list.with_item( id: dom_target(work_package, :menu, :move_to_backlog_bucket), label: t(".action_menu.move_to_backlog_bucket"), - href: move_to_bucket_dialog_project_backlogs_work_package_path(project, work_package, backlog_filter_params), + href: move_to_bucket_dialog_project_backlogs_work_package_path(project, work_package), content_arguments: { data: { controller: "async-dialog" } } ) do |item| item.with_leading_visual_icon(icon: :package) @@ -97,7 +97,7 @@ See COPYRIGHT and LICENSE files for more details. list.with_item( id: dom_target(work_package, :menu, :move_to_sprint), label: t(".action_menu.move_to_sprint"), - href: move_to_sprint_dialog_project_backlogs_work_package_path(project, work_package, backlog_filter_params), + href: move_to_sprint_dialog_project_backlogs_work_package_path(project, work_package), content_arguments: { data: { controller: "async-dialog" } } ) do |item| item.with_leading_visual_icon(icon: :zap) diff --git a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb index 9a29d3116d28..07b10a8a3e7f 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb @@ -100,7 +100,7 @@ def build_move_item(menu, label:, icon:, direction: nil, prev_id: nil) id: dom_target(work_package, :menu, label), label: I18n.t(label), tag: :button, - href: move_project_backlogs_work_package_path(project, work_package, backlog_filter_params), + href: move_project_backlogs_work_package_path(project, work_package), form_arguments: { method: :put, inputs: } ) do |item| item.with_leading_visual_icon(icon:) diff --git a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb index 35c9bf8bb058..50ac26d9094c 100644 --- a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb @@ -31,7 +31,6 @@ module Backlogs class WorkPackagesController < BaseController include OpTurbo::ComponentStream - include Backlogs::Concerns::ContainerLoading before_action :load_work_package @@ -64,14 +63,11 @@ def move_to_bucket_dialog end def move # rubocop:disable Metrics/AbcSize - # Capture the source before the call; the service reloads @work_package internally via #move_after. - source_sprint = @work_package.sprint - call = ::Backlogs::WorkPackages::UpdateService.new(user: current_user, story: @work_package) .call(**move_service_params) if call.success? - move_work_package_to_target_component_via_turbo_stream(source_sprint:, target_sprint: call.result.sprint) + reload_frame_via_turbo_stream("backlogs_container") if work_package_invisible_after_move?(call.result) backlog_name = call.result.backlog_bucket&.name || I18n.t(:label_inbox) @@ -90,36 +86,6 @@ def move # rubocop:disable Metrics/AbcSize private - def move_work_package_to_target_component_via_turbo_stream(source_sprint:, target_sprint:) - if source_sprint != target_sprint - replace_component_via_turbo_stream(sprint: source_sprint) - end - - replace_component_via_turbo_stream(sprint: target_sprint) - end - - def replace_component_via_turbo_stream(sprint:) - component = if sprint - sprint_component(sprint:) - else - backlog_component - end - - replace_via_turbo_stream(component:, method: :morph) - end - - def sprint_component(sprint:) - Backlogs::SprintComponent.new(sprint:, project: @project) - end - - def backlog_component - load_backlog_data - - Backlogs::BacklogComponent.new(buckets: @backlog_buckets, - work_packages_by_backlog_id: @work_packages_by_backlog_id, - project: @project) - end - def load_work_package @work_packages = WorkPackage.visible.where(project: @project).order_by_position @work_package = @work_packages.find(params.expect(:id)) From 2d26a6642363fb4618301553e068b1fdeef813a6 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:20:38 +0300 Subject: [PATCH 050/105] Fix specs --- .../backlogs/bucket_component_spec.rb | 7 - .../backlogs/sprint_component_spec.rb | 12 -- ...k_package_card_list_item_component_spec.rb | 22 --- .../work_package_card_menu_component_spec.rb | 40 ------ .../backlogs/work_packages_controller_spec.rb | 135 +++++------------- 5 files changed, 32 insertions(+), 184 deletions(-) diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index 6dcb60b6c915..0e0a7628d50b 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -180,13 +180,6 @@ def render_component expect(card["data-backlogs--story-split-url-value"]).to include("all=true") end end - - it "includes all=true in the action-menu src" do - expect(rendered_component).to have_element( - "include-fragment", - src: menu_project_backlogs_work_package_path(project, work_package, all: "true") - ) - end end context "when the user lacks the create_sprints permission" do diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index ec968ae19666..8d2ec25df525 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -128,18 +128,6 @@ def menu_items end end - context "when params[:all] is true" do - before do - vc_test_controller.params[:all] = "1" - end - - it "propagates all=true to the work package card URL" do - expect(rendered_component).to have_css(".Box-row#work_package_#{work_package1.id} .op-work-package-card") do |card| - expect(card["data-backlogs--story-split-url-value"]).to include("all=true") - end - end - end - it "renders the sprint kebab menu in the header" do expect(rendered_component).to have_element :"action-menu" end diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb index e0f4ad3dc2ff..4874b2c5d87d 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb @@ -107,17 +107,6 @@ expect(card["data-backlogs--story-split-url-value"]) .to end_with(project_backlogs_backlog_details_path(project, work_package)) end - - context "with params" do - let(:params) { { all: true } } - - it "passes params into card URLs" do - render_inline(item.card) - card = page.find(".op-work-package-card") - - expect(card["data-backlogs--story-split-url-value"]).to include("all=true") - end - end end describe "#card" do @@ -190,16 +179,5 @@ ) end end - - context "with params" do - let(:params) { { all: 1 } } - - it "passes params into the menu source" do - expect(rendered_card).to have_element( - "include-fragment", - src: menu_project_backlogs_work_package_path(project, work_package, all: 1) - ) - end - end end end diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb index 4bdab68a32c7..7e009c3cbc3e 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb @@ -97,16 +97,6 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, ) end - context "when params[:all] is true" do - before { vc_test_controller.params[:all] = "1" } - - it "adds the all param to the open details href" do - render_component - - expect(page).to have_css(%(#work_package_#{work_package.id}_menu_open_details[href*="all=true"])) - end - end - it "shows Open fullscreen link (full page)" do render_component @@ -353,16 +343,6 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, expect(page).to have_no_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) end - - context "when params[:all] is true" do - before { vc_test_controller.params[:all] = "1" } - - it "adds the all param to the move to sprint href" do - render_component(open_sprints_exist: true) - - expect(page).to have_css(%(#work_package_#{work_package.id}_menu_move_to_sprint[href*="all=true"])) - end - end end describe "Move to backlog inbox item" do @@ -397,16 +377,6 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, expect(page).to have_no_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_inbox\z/) end end - - context "when params[:all] is true" do - before { vc_test_controller.params[:all] = "1" } - - it "adds the all param to the form action for the inbox move" do - render_component - - expect(page).to have_css(%(form[action*="all=true"])) - end - end end describe "Move to backlog bucket item" do @@ -446,15 +416,5 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, expect(page).to have_no_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) end - - context "when params[:all] is true" do - before { vc_test_controller.params[:all] = "1" } - - it "adds the all param to the dialog href" do - render_component(other_buckets_exist: true) - - expect(page).to have_css(%(#work_package_#{work_package.id}_menu_move_to_backlog_bucket[href*="all=true"])) - end - end end end diff --git a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb index 18552b7a8cb2..b35362bee805 100644 --- a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb +++ b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb @@ -49,38 +49,6 @@ def turbo_stream_template(action:) let(:sprint) { create(:sprint, name: "Agile Sprint 1", project:) } let(:work_package) { create(:work_package, status:, sprint:, project:, type: type_feature) } - shared_examples "respecting the all param for inbox pagination" do - context "with an inbox over the pagination threshold" do - shared_let(:wps) { create_list(:work_package, 5, project:, status:) } - - before do - stub_const("Backlogs::InboxComponent::TRUNCATE_MIDDLE", 2) - end - - context "when all param is not present" do - let(:all) { nil } - - it "replaces the inbox with a show-more row in the stream" do - subject - - expect(response).to be_successful - expect(response.body).to include("inbox_project_#{project.id}_show_more") - end - end - - context "when all=true" do - let(:all) { "true" } - - it "replaces the inbox without a show-more row in the stream" do - subject - - expect(response).to be_successful - expect(response.body).not_to include("inbox_project_#{project.id}_show_more") - end - end - end - end - describe "load_work_package" do let(:params) { { project_id: project.id, id: work_package.id } } @@ -139,9 +107,8 @@ def turbo_stream_template(action:) subject expect(response).to be_successful - expect(response).to have_turbo_stream action: "replace", - target: "backlogs-sprint-component-#{sprint.id}", - method: "morph" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" end it "does not change the work_package's sprint and position" do @@ -162,10 +129,8 @@ def turbo_stream_template(action:) expect(response).to be_successful expect(response).to have_http_status :ok - expect(response) - .to have_turbo_stream action: "replace", target: "backlogs-sprint-component-#{sprint.id}", method: "morph" - expect(response) - .to have_turbo_stream action: "replace", target: "backlogs-sprint-component-#{other_sprint.id}", method: "morph" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" expect(assigns(:project)).to eq(project) expect(assigns(:work_package)).to eq(work_package_in_sprint) end @@ -201,10 +166,8 @@ def turbo_stream_template(action:) expect(response).to be_successful expect(response).to have_http_status :ok - expect(response) - .to have_turbo_stream action: "replace", target: "backlogs-sprint-component-#{sprint.id}", method: "morph" - expect(response) - .to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{project.id}", method: "morph" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" expect(assigns(:project)).to eq(project) expect(assigns(:work_package)).to eq(work_package_in_sprint) end @@ -241,8 +204,6 @@ def turbo_stream_template(action:) expect(work_package_in_sprint.reload).to have_attributes(sprint_id: nil, backlog_bucket_id: nil, position: 1) end end - - include_examples "respecting the all param for inbox pagination" end context "with a Backlog bucket as target" do @@ -256,11 +217,8 @@ def turbo_stream_template(action:) subject expect(response).to be_successful - expect(response).to have_turbo_stream action: "replace", - target: "backlogs-sprint-component-#{sprint.id}", - method: "morph" - expect(response).to have_turbo_stream action: "replace", - target: "backlogs-backlog-component-#{project.id}" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" end context "when the project is configured to exclude the work packages status from backlogs" do @@ -284,8 +242,6 @@ def turbo_stream_template(action:) expect(work_package_in_sprint.reload).to have_attributes(backlog_bucket: bucket, sprint_id: nil, position: 2) end - - include_examples "respecting the all param for inbox pagination" end context "with direction param" do @@ -296,8 +252,8 @@ def turbo_stream_template(action:) expect(response).to be_successful expect(response).to have_http_status :ok - expect(response) - .to have_turbo_stream action: "replace", target: "backlogs-sprint-component-#{sprint.id}", method: "morph" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" expect(assigns(:work_package)).to eq(work_package_in_sprint) end @@ -319,7 +275,8 @@ def turbo_stream_template(action:) expect(response).to have_http_status :unprocessable_entity expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" - expect(response).not_to have_turbo_stream action: "replace", target: "backlogs-sprint-component-#{sprint.id}" + expect(response).not_to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" end end end @@ -338,10 +295,8 @@ def turbo_stream_template(action:) subject expect(response).to be_successful - expect(response).to have_turbo_stream action: "replace", - target: "backlogs-backlog-component-#{project.id}" - expect(response).to have_turbo_stream action: "replace", - target: "backlogs-sprint-component-#{target_sprint.id}" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" expect(response).not_to have_turbo_stream action: "flash", target: "op-primer-flash-component" end @@ -351,8 +306,6 @@ def turbo_stream_template(action:) expect(inbox_work_package.reload).to have_attributes(sprint: target_sprint, backlog_bucket_id: nil, position: 1) end - - include_examples "respecting the all param for inbox pagination" end context "with the same Inbox as target" do @@ -364,8 +317,8 @@ def turbo_stream_template(action:) subject expect(response).to be_successful - expect(response).to have_turbo_stream action: "replace", - target: "backlogs-backlog-component-#{project.id}" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" expect(response).not_to have_turbo_stream action: "flash", target: "op-primer-flash-component" end @@ -373,8 +326,6 @@ def turbo_stream_template(action:) subject expect(inbox_work_package.reload).to have_attributes(sprint: nil, backlog_bucket: nil, position: 2) end - - include_examples "respecting the all param for inbox pagination" end context "with a Backlog bucket as target" do @@ -388,8 +339,8 @@ def turbo_stream_template(action:) subject expect(response).to be_successful - expect(response).to have_turbo_stream action: "replace", - target: "backlogs-backlog-component-#{project.id}" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" expect(response).not_to have_turbo_stream action: "flash", target: "op-primer-flash-component" end @@ -397,8 +348,6 @@ def turbo_stream_template(action:) subject expect(inbox_work_package.reload).to have_attributes(backlog_bucket: bucket, sprint_id: nil, position: 2) end - - include_examples "respecting the all param for inbox pagination" end context "with direction param" do @@ -409,7 +358,9 @@ def turbo_stream_template(action:) subject expect(response).to be_successful - expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{project.id}" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" + expect(assigns(:work_package)).to eq(inbox_work_package) end @@ -418,8 +369,6 @@ def turbo_stream_template(action:) expect(inbox_work_package.reload).to have_attributes(backlog_bucket_id: nil, sprint_id: nil, position: 1) end - - include_examples "respecting the all param for inbox pagination" end end @@ -438,10 +387,9 @@ def turbo_stream_template(action:) subject expect(response).to be_successful - expect(response).to have_turbo_stream action: "replace", - target: "backlogs-backlog-component-#{project.id}" - expect(response).to have_turbo_stream action: "replace", - target: "backlogs-sprint-component-#{target_sprint.id}" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" + expect(response).not_to have_turbo_stream action: "flash", target: "op-primer-flash-component" end @@ -450,8 +398,6 @@ def turbo_stream_template(action:) expect(bucket_work_package.reload).to have_attributes(sprint: target_sprint, backlog_bucket: nil, position: 1) end - - include_examples "respecting the all param for inbox pagination" end context "with the Inbox as target" do @@ -463,8 +409,8 @@ def turbo_stream_template(action:) subject expect(response).to be_successful - expect(response).to have_turbo_stream action: "replace", - target: "backlogs-backlog-component-#{project.id}" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" expect(response).not_to have_turbo_stream action: "flash", target: "op-primer-flash-component" end @@ -473,8 +419,6 @@ def turbo_stream_template(action:) expect(bucket_work_package.reload).to have_attributes(backlog_bucket_id: nil, sprint_id: nil, position: 2) end - - include_examples "respecting the all param for inbox pagination" end context "with the same Backlog bucket as target" do @@ -486,8 +430,8 @@ def turbo_stream_template(action:) subject expect(response).to be_successful - expect(response).to have_turbo_stream action: "replace", - target: "backlogs-backlog-component-#{project.id}" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" expect(response).not_to have_turbo_stream action: "flash", target: "op-primer-flash-component" end @@ -496,8 +440,6 @@ def turbo_stream_template(action:) expect(bucket_work_package.reload).to have_attributes(backlog_bucket: bucket, sprint_id: nil, position: 2) end - - include_examples "respecting the all param for inbox pagination" end context "with another Backlog bucket as target" do @@ -511,8 +453,8 @@ def turbo_stream_template(action:) subject expect(response).to be_successful - expect(response).to have_turbo_stream action: "replace", - target: "backlogs-backlog-component-#{project.id}" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" expect(response).not_to have_turbo_stream action: "flash", target: "op-primer-flash-component" end @@ -521,8 +463,6 @@ def turbo_stream_template(action:) expect(bucket_work_package.reload).to have_attributes(backlog_bucket: other_bucket, sprint_id: nil, position: 2) end - - include_examples "respecting the all param for inbox pagination" end context "with direction param" do @@ -532,7 +472,8 @@ def turbo_stream_template(action:) subject expect(response).to be_successful - expect(response).to have_turbo_stream action: "replace", target: "backlogs-backlog-component-#{project.id}" + expect(response).to have_turbo_stream action: "turbo_frame_reload", + target: "backlogs_container" expect(assigns(:work_package)).to eq(bucket_work_package) end @@ -541,8 +482,6 @@ def turbo_stream_template(action:) expect(bucket_work_package.reload).to have_attributes(backlog_bucket_id: bucket.id, sprint_id: nil, position: 1) end - - include_examples "respecting the all param for inbox pagination" end end @@ -595,16 +534,6 @@ def turbo_stream_template(action:) expect(response.body).to include(I18n.t(:"js.button_open_details")) end - context "when all=true is in params" do - let(:params) { { project_id: project.id, id: work_package_id, all: "true" } } - - it "embeds the all query in deferred action URLs" do - subject - - expect(response.body).to match(/all=true/) - end - end - context "when another open sprint exists" do let!(:other_open_sprint) { create(:sprint, name: "Sprint 2", project:) } From 9936030e9099c8874a4b2c5e6c30fd76bfeafb67 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:29:51 +0300 Subject: [PATCH 051/105] Use the backlogs story#openSplitPane action inside the work package menu to open the details view. --- .../controllers/dynamic/backlogs/story.controller.ts | 2 +- .../backlogs/work_package_card_menu_component.html.erb | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts index 6ea7a272eee5..5fe0d8415b0d 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts @@ -178,7 +178,7 @@ export default class StoryController extends Controller implements } } - private openSplitPane():void { + openSplitPane():void { Turbo.visit(this.splitUrlValue, { frame: 'content-bodyRight', action: 'advance' }); } diff --git a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb index b794178e9e98..2418453fc6b6 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb @@ -31,10 +31,9 @@ See COPYRIGHT and LICENSE files for more details. render(Primer::Alpha::ActionMenu::List.new(menu_id:)) do |list| list.with_item( id: dom_target(work_package, :menu, :open_details), - tag: :a, + tag: :button, label: t(:"js.button_open_details"), - href: project_backlogs_backlog_details_path(project, work_package), - content_arguments: { data: { turbo_frame: "content-bodyRight", turbo_action: "advance" } } + content_arguments: { data: { action: "backlogs--story#openSplitPane" } } ) do |item| item.with_leading_visual_icon(icon: :"op-view-split") end From a9420055ccd9dcb740157434780409704390cc26 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 24 Jun 2026 15:30:40 +0200 Subject: [PATCH 052/105] Update menu specs for openSplitPane action The open-details menu entry now renders a button that fires the Stimulus `backlogs--story#openSplitPane` action rather than a turbo-frame anchor, so three menu specs and the now-stale open-details URL assertion change. Semantic split-url coverage moves to the card item spec that derives it. --- ...ork_package_card_list_item_component_spec.rb | 17 +++++++++++++++++ .../work_package_card_menu_component_spec.rb | 12 ++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb index 4874b2c5d87d..6649bd56114d 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb @@ -107,6 +107,23 @@ expect(card["data-backlogs--story-split-url-value"]) .to end_with(project_backlogs_backlog_details_path(project, work_package)) end + + context "in semantic mode", with_settings: { work_packages_identifier: "semantic" } do + let(:project) { create(:project, types: [type_feature], identifier: "STORY") } + let(:sprint) do + create(:sprint, project:, name: "Sprint 1", start_date: Date.yesterday, finish_date: Date.tomorrow) + end + + it "uses the semantic identifier in the split-view route and display id" do + render_inline(item.card) + card = page.find(".op-work-package-card") + semantic_id = work_package.reload.identifier + + expect(semantic_id).to start_with("STORY-") + expect(card["data-backlogs--story-split-url-value"]).to end_with("/details/#{semantic_id}") + expect(card["data-backlogs--story-display-id-value"]).to eq(semantic_id) + end + end end describe "#card" do diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb index 7e009c3cbc3e..f6705a1a0f50 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb @@ -80,19 +80,19 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, render_component expect(page).to have_element(:ul, id: /\Awork_package_#{work_package.id}_menu-list\z/) - expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_open_details\z/) + expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_open_details\z/) expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_open_fullscreen\z/) expect(page).to have_element(:"clipboard-copy", id: /\Awork_package_#{work_package.id}_menu_copy_url_to_clipboard\z/) expect(page).to have_element(:"clipboard-copy", id: /\Awork_package_#{work_package.id}_menu_copy_work_package_id\z/) end - it "shows Open details link (split view)" do + it "shows Open details action (split view)" do render_component expect(page).to have_text(I18n.t(:"js.button_open_details")) expect(page).to have_octicon(:"op-view-split") expect(page).to have_css( - "a[data-turbo-frame='content-bodyRight'][data-turbo-action='advance']", + "button[data-action='backlogs--story#openSplitPane']", text: I18n.t(:"js.button_open_details") ) end @@ -136,16 +136,12 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, with_settings: { work_packages_identifier: "semantic" } do let(:project) { create(:project, types: [type_feature, type_task], identifier: "STORY") } - it "uses the semantic displayId in the open details, fullscreen, and clipboard URLs" do + it "uses the semantic displayId in the fullscreen and clipboard URLs" do render_component semantic_id = work_package.reload.identifier expect(semantic_id).to start_with("STORY-") - details = page.find_by_id("work_package_#{work_package.id}_menu_open_details") - expect(details[:href]).to include("/details/#{semantic_id}") - expect(details[:href]).not_to include("/details/#{work_package.id}") - fullscreen = page.find_by_id("work_package_#{work_package.id}_menu_open_fullscreen") expect(fullscreen[:href]).to end_with("/work_packages/#{semantic_id}") expect(fullscreen[:href]).not_to include("/work_packages/#{work_package.id}") From d333de0c8b1393e5aca07db400f3cb558b8c97a7 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 24 Jun 2026 15:56:49 +0200 Subject: [PATCH 053/105] Fix frontend typed-lint on sortable-lists specs The tsconfig reorganisation merged from dev in #23906 brought these spec files under type-aware linting for the first time. --- .../dynamic/sortable-lists.controller.spec.ts | 13 ++++++++----- .../dynamic/sortable-lists/item.controller.spec.ts | 2 -- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index b460703f8e2e..df5476cb5510 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -26,11 +26,12 @@ // See COPYRIGHT and LICENSE files for more details. //++ -/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import type { autoScrollForElements as autoScrollForElementsFn } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import type { dropTargetForElements as dropTargetForElementsFn, monitorForElements as monitorForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { waitFor } from '@testing-library/dom'; +import { type Mock } from 'vitest'; import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; import type SortableListsControllerType from './sortable-lists.controller'; import type { @@ -50,8 +51,8 @@ describe('Sortable lists controller', () => { let ctx:StimulusTestContext; let fixture:HTMLElement; - let fetchMock:ReturnType; - let renderStreamMessageMock:ReturnType; + let fetchMock:Mock; + let renderStreamMessageMock:Mock; beforeAll(async () => { vi.doMock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({ @@ -149,7 +150,7 @@ describe('Sortable lists controller', () => { async function dropCurrentItemOnList(sourceElement:HTMLElement, list:HTMLElement) { const monitorOptions = vi.mocked(monitorForElements).mock.lastCall?.[0]; - await monitorOptions?.onDrop?.({ + monitorOptions?.onDrop?.({ source: sourcePayload( sourceElement, itemData(sourceElement.getAttribute('data-sortable-lists--item-id-value')!), @@ -176,6 +177,8 @@ describe('Sortable lists controller', () => { }, }, }); + + await flushPromises(); } function itemData(itemId = '1', type = 'work_package') { @@ -271,7 +274,7 @@ describe('Sortable lists controller', () => { const secondRootSource = fixture.querySelector('[data-sortable-lists--item-id-value="10"]')!; const secondRootTarget = fixture.querySelector('[data-sortable-lists--item-id-value="11"]')!; - await firstRootMonitor?.onDrop?.({ + firstRootMonitor?.onDrop?.({ source: sourcePayload(secondRootSource, itemData('10', 'work_package')), location: { initial: { diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index 5aba932dbb7e..c4ace533e6f6 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -26,8 +26,6 @@ // See COPYRIGHT and LICENSE files for more details. //++ -/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */ - import type { draggable as draggableFn, dropTargetForElements as dropTargetForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import type { setCustomNativeDragPreview as setCustomNativeDragPreviewFn } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import type { preventUnhandled as preventUnhandledType } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; From ac0fdf30c5c8120166d8c6b7b3354a88ed103189 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 17 Jun 2026 21:53:09 +0200 Subject: [PATCH 054/105] Split sortable-lists DnD into outlet controllers The drag-and-drop subsystem now splits across three Stimulus controllers: a root that owns the move request, the drag monitor, and auto-scroll; a list controller that owns each drop target; and an item controller that owns the drag source and its edge indicator. Children read shared root state through a typed outlet reference instead of walking the DOM. The Backlogs views are wired to the new controllers. --- .../content/modules/_backlogs.sass | 2 +- .../dynamic/sortable-lists.controller.spec.ts | 177 ++++++---------- .../dynamic/sortable-lists.controller.ts | 53 ++--- .../sortable-lists/drag-and-drop.spec.ts | 52 ++--- .../dynamic/sortable-lists/drag-and-drop.ts | 28 ++- .../sortable-lists/item.controller.spec.ts | 103 ++++++---- .../dynamic/sortable-lists/item.controller.ts | 48 +++-- .../dynamic/sortable-lists/list-dom.ts | 8 +- .../sortable-lists/list.controller.spec.ts | 190 ++++++++++++++++++ .../dynamic/sortable-lists/list.controller.ts | 122 +++++++++++ .../backlogs/inbox_component.html.erb | 4 +- .../work_package_card_list_component.rb | 6 +- .../backlogs/backlog/_backlog_list.html.erb | 4 +- .../app/views/backlogs/backlog/show.html.erb | 4 +- .../backlogs/bucket_component_spec.rb | 8 +- .../backlogs/inbox_component_spec.rb | 8 +- .../backlogs/sprint_component_spec.rb | 8 +- .../work_package_card_list_component_spec.rb | 14 +- .../spec/requests/backlogs/backlog_spec.rb | 18 +- 19 files changed, 574 insertions(+), 283 deletions(-) create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts diff --git a/frontend/src/global_styles/content/modules/_backlogs.sass b/frontend/src/global_styles/content/modules/_backlogs.sass index b9944613e77f..f4f8a5ea82e2 100644 --- a/frontend/src/global_styles/content/modules/_backlogs.sass +++ b/frontend/src/global_styles/content/modules/_backlogs.sass @@ -49,7 +49,7 @@ container-name: backlogsListsContainer container-type: inline-size - [data-sortable-lists-target~="list"][aria-busy="true"] + [data-controller~="sortable-lists--list"][aria-busy="true"] cursor: progress .Box-list diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index df5476cb5510..602aaa8d5996 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -29,7 +29,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import type { autoScrollForElements as autoScrollForElementsFn } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; -import type { dropTargetForElements as dropTargetForElementsFn, monitorForElements as monitorForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import type { monitorForElements as monitorForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { waitFor } from '@testing-library/dom'; import { type Mock } from 'vitest'; import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; @@ -42,7 +42,6 @@ import type { describe('Sortable lists controller', () => { const flushPromises = () => new Promise((resolve) => setTimeout(resolve)); - let dropTargetForElements:typeof dropTargetForElementsFn; let monitorForElements:typeof monitorForElementsFn; let autoScrollForElements:typeof autoScrollForElementsFn; let SortableListsController:typeof SortableListsControllerType; @@ -65,7 +64,25 @@ describe('Sortable lists controller', () => { autoScrollForElements: vi.fn(() => vi.fn()), })); - ({ dropTargetForElements, monitorForElements } = await import('@atlaskit/pragmatic-drag-and-drop/element/adapter')); + // This spec mounts the real item controller, which pulls in these modules. + // Tests share one module registry (the runner does not isolate spec files), + // so importing the real versions here would leak into the item controller + // spec and break its spies. Mock them to keep the shared cache inert. + vi.doMock('@atlaskit/pragmatic-drag-and-drop/combine', () => ({ + combine: vi.fn((...cleanups:(() => void)[]) => vi.fn(() => { + cleanups.forEach((cleanup) => cleanup()); + })), + })); + + vi.doMock('@atlaskit/pragmatic-drag-and-drop/prevent-unhandled', () => ({ + preventUnhandled: { start: vi.fn(), stop: vi.fn() }, + })); + + vi.doMock('@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview', () => ({ + setCustomNativeDragPreview: vi.fn(), + })); + + ({ monitorForElements } = await import('@atlaskit/pragmatic-drag-and-drop/element/adapter')); ({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element')); ({ default: SortableListsController } = await import('./sortable-lists.controller')); ({ sortableItemData, sortableListData } = await import('./sortable-lists/drag-and-drop')); @@ -88,43 +105,34 @@ describe('Sortable lists controller', () => { function itemRow(id:string):HTMLLIElement { const row = document.createElement('li'); - + row.setAttribute('data-controller', 'sortable-lists--item'); row.setAttribute('data-sortable-lists--item-id-value', id); row.setAttribute('data-sortable-lists--item-type-value', 'work_package'); - return row; } function renderFixture({ acceptedType = null, moveUrlTemplate = '/move/{id}', - }:{ - acceptedType?:string|null; - moveUrlTemplate?:string|null; - } = {}) { + }:{ acceptedType?:string|null; moveUrlTemplate?:string|null } = {}) { fixture.innerHTML = `
                          -
                            -
                              +
                                +
                                  `; - - const [sourceList, targetList] = Array.from(fixture.querySelectorAll('[data-sortable-lists-target="list"]')); - const root = fixture.querySelector('[data-controller="sortable-lists"]')!; - + const [sourceList, targetList] = Array.from(fixture.querySelectorAll('[data-controller~="sortable-lists--list"]')); + const root = fixture.querySelector('#sortable-root')!; sourceList.append(itemRow('1'), itemRow('2'), itemRow('3')); targetList.append(itemRow('4'), itemRow('5')); - - return { - root, - sourceList, - targetList, - firstSourceItem: sourceList.querySelector('[data-sortable-lists--item-id-value="1"]')!, - }; + return { root, sourceList, targetList, firstSourceItem: sourceList.querySelector('[data-sortable-lists--item-id-value="1"]')! }; } function renderScrollableFixture(values = '') { @@ -137,16 +145,6 @@ describe('Sortable lists controller', () => { return fixture.querySelector('[data-sortable-lists-target="scrollable"]')!; } - function renderInvalidListFixture() { - fixture.innerHTML = ` -
                                  -
                                    -
                                    - `; - - return fixture.querySelector('[data-sortable-lists-target="list"]')!; - } - async function dropCurrentItemOnList(sourceElement:HTMLElement, list:HTMLElement) { const monitorOptions = vi.mocked(monitorForElements).mock.lastCall?.[0]; @@ -165,8 +163,8 @@ describe('Sortable lists controller', () => { dropTargetRecord( list, sortableListData({ - type: list.getAttribute('data-sortable-lists-list-type')!, - listId: list.getAttribute('data-sortable-lists-list-id'), + type: list.getAttribute('data-sortable-lists--list-type-value')!, + listId: list.getAttribute('data-sortable-lists--list-id-value'), }), ), ], @@ -202,10 +200,6 @@ describe('Sortable lists controller', () => { }; } - function dropTargetOptionsFor(element:HTMLElement) { - return vi.mocked(dropTargetForElements).mock.calls.find(([options]) => options.element === element)?.[0]; - } - function itemIds(list:HTMLElement):string[] { return Array.from(list.querySelectorAll('[data-sortable-lists--item-id-value]')) .map((element) => element.getAttribute('data-sortable-lists--item-id-value')!); @@ -225,6 +219,8 @@ describe('Sortable lists controller', () => { ctx = await setupStimulusTest({ controllers: { 'sortable-lists': SortableListsController, + 'sortable-lists--list': (await import('./sortable-lists/list.controller')).default, + 'sortable-lists--item': (await import('./sortable-lists/item.controller')).default, }, }); fixture = ctx.container; @@ -248,22 +244,28 @@ describe('Sortable lists controller', () => { it('ignores drops that belong to another sortable lists root', async () => { fixture.innerHTML = `
                                    -
                                      -
                                    • +
                                        +
                                    -
                                      -
                                    • -
                                    • +
                                        +
                                      • +
                                    `; @@ -333,14 +335,16 @@ describe('Sortable lists controller', () => { data-controller="sortable-lists" data-sortable-lists-accepted-type-value="work_package" data-sortable-lists-move-url-template-value="/projects/demo/backlogs/work_packages/{id}/move" + data-sortable-lists-sortable-lists--list-outlet="#backlogs-list [data-controller~='sortable-lists--list']" + data-sortable-lists-sortable-lists--item-outlet="#backlogs-list [data-controller~='sortable-lists--item']" > -
                                      -
                                        +
                                          +
                                            `; const [sourceList, targetList] = Array.from( - fixture.querySelectorAll('[data-sortable-lists-target="list"]'), + fixture.querySelectorAll('[data-controller~="sortable-lists--list"]'), ); sourceList.append(itemRow('1')); targetList.append(itemRow('4')); @@ -408,11 +412,6 @@ describe('Sortable lists controller', () => { source: sourcePayload(firstSourceItem), initial: {} as never, })).toBe(false); - expect(dropTargetOptionsFor(targetList)?.canDrop?.({ - element: targetList, - input: input(), - source: sourcePayload(firstSourceItem), - })).toBe(false); resolveMove!(new Response('', { status: 200 })); await flushPromises(); @@ -524,67 +523,6 @@ describe('Sortable lists controller', () => { window.removeEventListener('op:toasters:add', onToast); }); - it('registers Backlogs lists as drop targets', async () => { - const { sourceList, targetList } = renderFixture(); - - await ctx.nextFrame(); - - expect(dropTargetForElements).toHaveBeenCalledWith(expect.objectContaining({ - element: sourceList, - })); - expect(dropTargetForElements).toHaveBeenCalledWith(expect.objectContaining({ - element: targetList, - })); - }); - - it('does not register list targets without sortable list data', async () => { - const list = renderInvalidListFixture(); - - await ctx.nextFrame(); - - expect(dropTargetForElements).not.toHaveBeenCalledWith(expect.objectContaining({ - element: list, - })); - }); - - it('accepts item drops when the source type matches the root accepted type', async () => { - const { targetList, firstSourceItem } = renderFixture({ acceptedType: 'work_package' }); - - await ctx.nextFrame(); - - expect(dropTargetOptionsFor(targetList)?.canDrop?.({ - element: targetList, - input: input(), - source: sourcePayload(firstSourceItem, itemData('1', 'work_package')), - })).toBe(true); - }); - - it('applies the accepted item type to every list target in the controller root', async () => { - const { sourceList, targetList, firstSourceItem } = renderFixture({ acceptedType: 'work_package' }); - - await ctx.nextFrame(); - - for (const list of [sourceList, targetList]) { - expect(dropTargetOptionsFor(list)?.canDrop?.({ - element: list, - input: input(), - source: sourcePayload(firstSourceItem, itemData('1', 'meeting_agenda_item')), - })).toBe(false); - } - }); - - it('rejects item drops when the source type does not match the root accepted type', async () => { - const { targetList, firstSourceItem } = renderFixture({ acceptedType: 'work_package' }); - - await ctx.nextFrame(); - - expect(dropTargetOptionsFor(targetList)?.canDrop?.({ - element: targetList, - input: input(), - source: sourcePayload(firstSourceItem, itemData('1', 'meeting_agenda_item')), - })).toBe(false); - }); - it('registers scrollable targets for vertical sortable list auto-scrolling', async () => { const scrollable = renderScrollableFixture(); @@ -631,4 +569,19 @@ describe('Sortable lists controller', () => { expect(scrollableCleanup).toHaveBeenCalledOnce(); }); + + it('hands the root reference to connected list and item controllers', async () => { + const { root, sourceList, firstSourceItem } = renderFixture({ acceptedType: 'work_package' }); + await ctx.nextFrame(); + // Outlet connections happen after the controller frame; a second frame + // ensures the hand-over callbacks have fired. + await ctx.nextFrame(); + + const listController = ctx.application.getControllerForElementAndIdentifier(sourceList, 'sortable-lists--list') as unknown as { ['root']?:unknown }; + const itemController = ctx.application.getControllerForElementAndIdentifier(firstSourceItem, 'sortable-lists--item') as unknown as { ['root']?:unknown }; + const rootController = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists'); + + expect(listController.root).toBe(rootController); + expect(itemController.root).toBe(rootController); + }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index e0f593cd488f..41197bcde6fc 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -28,7 +28,6 @@ import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import { - dropTargetForElements, monitorForElements, type ElementEventPayloadMap, } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; @@ -38,12 +37,11 @@ import { debugLog } from 'core-app/shared/helpers/debug_output'; import { flipMove } from 'core-stimulus/helpers/flip-helper'; import { parseTemplate } from 'url-template'; import { - acceptsSortableItemType, buildMoveFormData, isSortableItemData, resolveDropIntent, - resolveListTargetData, type SortableListData, + type SortableListsRoot, } from './sortable-lists/drag-and-drop'; import { captureRowPositions, @@ -57,12 +55,14 @@ type ElementDropPayload = ElementEventPayloadMap['onDrop']; type AutoScrollAllowedAxis = 'vertical'|'horizontal'|'all'; type AutoScrollMaxScrollSpeed = 'standard'|'fast'; type MoveResult = { ok:true }|{ ok:false; showToast:boolean }; +interface RootAwareChild { connectRoot(root:SortableListsRoot):void; disconnectRoot():void } const allowedAxes = new Set(['vertical', 'horizontal', 'all']); const maxScrollSpeeds = new Set(['standard', 'fast']); export default class SortableListsController extends Controller { - static targets = ['list', 'scrollable']; + static targets = ['scrollable']; + static outlets = ['sortable-lists--list', 'sortable-lists--item']; static values = { acceptedType: String, @@ -71,8 +71,9 @@ export default class SortableListsController extends Controller { maxScrollSpeed: { type: String, default: 'standard' }, }; - declare readonly listTargets:HTMLElement[]; declare readonly scrollableTargets:HTMLElement[]; + declare readonly sortableListsListOutlets:import('./sortable-lists/list.controller').default[]; + declare readonly sortableListsItemOutlets:RootAwareChild[]; declare readonly acceptedTypeValue:string; declare readonly hasAcceptedTypeValue:boolean; @@ -82,7 +83,6 @@ export default class SortableListsController extends Controller { declare readonly maxScrollSpeedValue:string; private monitorCleanupFn?:CleanupFn; - private listCleanupFns = new Map(); private scrollableCleanupFns = new Map(); connect():void { @@ -97,34 +97,24 @@ export default class SortableListsController extends Controller { disconnect():void { this.monitorCleanupFn?.(); this.monitorCleanupFn = undefined; - this.listCleanupFns.forEach((cleanup) => cleanup()); - this.listCleanupFns.clear(); this.scrollableCleanupFns.forEach((cleanup) => cleanup()); this.scrollableCleanupFns.clear(); } - listTargetConnected(element:HTMLElement):void { - const listData = resolveListTargetData(element); - if (!listData) { - return; - } + sortableListsListOutletConnected(list:RootAwareChild):void { + list.connectRoot(this); + } - const cleanup = dropTargetForElements({ - element, - canDrop: ({ source }) => !this.moving && isSortableItemData(source.data) && acceptsSortableItemType({ - acceptedType: this.acceptedType, - type: source.data.type, - }), - getData: () => listData, - getIsSticky: () => false, - }); + sortableListsListOutletDisconnected(list:RootAwareChild):void { + list.disconnectRoot(); + } - this.listCleanupFns.set(element, cleanup); + sortableListsItemOutletConnected(item:RootAwareChild):void { + item.connectRoot(this); } - listTargetDisconnected(element:HTMLElement):void { - this.listCleanupFns.get(element)?.(); - this.listCleanupFns.delete(element); + sortableListsItemOutletDisconnected(item:RootAwareChild):void { + item.disconnectRoot(); } scrollableTargetConnected(element:HTMLElement):void { @@ -147,9 +137,9 @@ export default class SortableListsController extends Controller { return allowedAxes.has(this.allowedAxisValue) ? this.allowedAxisValue as AutoScrollAllowedAxis : 'vertical'; } - private get acceptedType():string|null { - // The accepted type is scoped to this controller instance, so every list target - // inside one sortable-lists root accepts the same sortable item type. + get acceptedType():string|null { + // The accepted type is scoped to this controller instance, so every list + // outlet inside one sortable-lists root accepts the same sortable item type. return this.hasAcceptedTypeValue ? this.acceptedTypeValue : null; } @@ -157,7 +147,7 @@ export default class SortableListsController extends Controller { return maxScrollSpeeds.has(this.maxScrollSpeedValue) ? this.maxScrollSpeedValue as AutoScrollMaxScrollSpeed : 'standard'; } - private get moving():boolean { + get moving():boolean { return this.element.hasAttribute(sortableListsMovingAttribute); } @@ -295,11 +285,10 @@ export default class SortableListsController extends Controller { private setMoving(moving:boolean):void { if (moving) { this.element.setAttribute(sortableListsMovingAttribute, 'true'); - this.listTargets.forEach((list) => list.setAttribute('aria-busy', 'true')); } else { this.element.removeAttribute(sortableListsMovingAttribute); - this.listTargets.forEach((list) => list.removeAttribute('aria-busy')); } + this.sortableListsListOutlets.forEach((list) => list.reflectMoving(moving)); } private dispatchErrorToast():void { diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts index f2a5e6d67164..aa34d0fbacdf 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -36,7 +36,6 @@ import { isSortableItemData, isSortableListData, resolveDropIntent, - resolveListTargetData, resolvePreviousSortableItemId, sortableItemData, sortableListData, @@ -131,6 +130,20 @@ describe('sortable lists drag and drop helpers', () => { expect(data.itemId).toEqual('42'); expect(isSortableItemData(data)).toBe(true); }); + + it('carries the root element on the item payload when provided', () => { + const root = document.createElement('div'); + const data = sortableItemData({ itemId: '1', type: 'work_package', rootElement: root }); + + expect(data.rootElement).toBe(root); + expect(isSortableItemData(data)).toBe(true); + }); + + it('defaults the item payload root element to null', () => { + const data = sortableItemData({ itemId: '1', type: 'work_package' }); + + expect(data.rootElement).toBeNull(); + }); }); describe('acceptsSortableItemType', () => { @@ -240,29 +253,6 @@ describe('sortable lists drag and drop helpers', () => { }); }); - describe('resolveListTargetData', () => { - it('reads the list type and id from the list element', () => { - const list = document.createElement('ul'); - - list.setAttribute('data-sortable-lists-list-type', 'sprint'); - list.setAttribute('data-sortable-lists-list-id', '12'); - - expect(resolveListTargetData(list)).toEqual(expect.objectContaining({ type: 'sprint', listId: '12' })); - }); - - it('uses null as the list id for lists without an id', () => { - const list = document.createElement('ul'); - - list.setAttribute('data-sortable-lists-list-type', 'inbox'); - - expect(resolveListTargetData(list)).toEqual(expect.objectContaining({ type: 'inbox', listId: null })); - }); - - it('returns null for elements without a list type', () => { - expect(resolveListTargetData(document.createElement('ul'))).toBeNull(); - }); - }); - describe('resolveDropIntent', () => { function dropLocation({ dropTargets = [], @@ -278,13 +268,11 @@ describe('sortable lists drag and drop helpers', () => { } as unknown as DragLocationHistory; } - function buildList({ listId = '7' } = {}) { + function buildList() { const root = document.createElement('div'); const list = document.createElement('ul'); - list.setAttribute('data-sortable-lists-target', 'list'); - list.setAttribute('data-sortable-lists-list-type', 'backlog_bucket'); - list.setAttribute('data-sortable-lists-list-id', listId); + list.setAttribute('data-controller', 'sortable-lists--list'); root.appendChild(list); return { root, list }; @@ -352,9 +340,7 @@ describe('sortable lists drag and drop helpers', () => { const sourceList = document.createElement('ul'); const source = itemRow('1'); - sourceList.setAttribute('data-sortable-lists-target', 'list'); - sourceList.setAttribute('data-sortable-lists-list-type', 'sprint'); - sourceList.setAttribute('data-sortable-lists-list-id', '3'); + sourceList.setAttribute('data-controller', 'sortable-lists--list'); sourceList.append(source); list.append(itemRow('4'), itemRow('5')); root.append(sourceList); @@ -415,9 +401,7 @@ describe('sortable lists drag and drop helpers', () => { const source = itemRow('1'); const header = document.createElement('header'); - sourceList.setAttribute('data-sortable-lists-target', 'list'); - sourceList.setAttribute('data-sortable-lists-list-type', 'sprint'); - sourceList.setAttribute('data-sortable-lists-list-id', '3'); + sourceList.setAttribute('data-controller', 'sortable-lists--list'); sourceList.append(source); list.append(header, itemRow('4')); root.append(sourceList); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index aefb25537250..aebd305f55e1 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -36,9 +36,7 @@ import { resolveItemId, resolveListAppendPreviousItemId, resolvePreviousItemId, - sortableListIdAttribute, sortableListSelector, - sortableListTypeAttribute, } from './list-dom'; // The Pragmatic DnD payloads exchanged between the sortable-lists root and @@ -50,6 +48,7 @@ export interface SortableItemData extends Record { [sortableItemDataKey]:true; type:string; itemId:string; + rootElement:HTMLElement|null; } export interface SortableListData extends Record { @@ -58,6 +57,15 @@ export interface SortableListData extends Record { listId:string|null; } +// Implemented by the sortable-lists root controller and handed to list/item +// controllers via outlet callbacks, so children read shared state through a +// typed reference instead of walking the DOM. +export interface SortableListsRoot { + readonly element:HTMLElement; + readonly moving:boolean; + readonly acceptedType:string|null; +} + export function isSortableItemData(data:Record):data is SortableItemData { return data[sortableItemDataKey] === true && typeof data.type === 'string' @@ -76,14 +84,17 @@ export function isSortableListData(data:Record):data is export function sortableItemData({ type, itemId, + rootElement = null, }:{ type:string; itemId:string; + rootElement?:HTMLElement|null; }):SortableItemData { return { [sortableItemDataKey]: true, type, itemId, + rootElement, }; } @@ -119,19 +130,6 @@ export function buildMoveFormData({ return data; } -export function resolveListTargetData(element:Element):SortableListData|null { - const type = element.getAttribute(sortableListTypeAttribute); - - if (!type) { - return null; - } - - return sortableListData({ - type, - listId: element.getAttribute(sortableListIdAttribute), - }); -} - export function acceptsSortableItemType({ acceptedType, type, diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index c4ace533e6f6..4f684e561cd2 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -31,6 +31,7 @@ import type { setCustomNativeDragPreview as setCustomNativeDragPreviewFn } from import type { preventUnhandled as preventUnhandledType } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; import type ItemControllerType from './item.controller'; +import type { SortableListsRoot } from './drag-and-drop'; describe('Sortable lists item controller', () => { let draggable:typeof draggableFn; @@ -81,18 +82,27 @@ describe('Sortable lists item controller', () => { Object.defineProperty(controller, 'element', { value: element }); Object.defineProperty(controller, 'idValue', { value: '1' }); - Object.defineProperty(controller, 'hasMoveUrlValue', { value: false }); Object.defineProperty(controller, 'typeValue', { value: 'item' }); return controller; } - function connectedControllerFor(element:HTMLElement, { handle = null }:{ handle?:HTMLElement|null } = {}) { + function fakeRoot( + element = document.createElement('div'), + { moving = false, acceptedType = null as string|null } = {}, + ):SortableListsRoot { + Object.defineProperty(element, 'isConnected', { value: true, configurable: true }); + return { element, moving, acceptedType }; + } + + function connectedControllerFor( + element:HTMLElement, + { handle = null, root = fakeRoot() }:{ handle?:HTMLElement|null; root?:SortableListsRoot|null } = {}, + ) { const controller = Object.create(ItemController.prototype) as InstanceType; Object.defineProperty(controller, 'element', { value: element }); Object.defineProperty(controller, 'idValue', { value: '123' }); - Object.defineProperty(controller, 'hasMoveUrlValue', { value: false }); Object.defineProperty(controller, 'typeValue', { value: 'item' }); Object.defineProperty(controller, 'hasHandleTarget', { value: handle !== null }); if (handle) { @@ -100,6 +110,9 @@ describe('Sortable lists item controller', () => { } controller.connect(); + if (root) { + controller.connectRoot(root); + } return controller; } @@ -220,67 +233,68 @@ describe('Sortable lists item controller', () => { }); it('does not accept itself as an item drop target', () => { + const root = document.createElement('div'); const element = document.createElement('article'); - connectedControllerFor(element); + connectedControllerFor(element, { root: fakeRoot(root) }); expect(vi.mocked(dropTargetForElements).mock.lastCall?.[0].canDrop?.({ element, input: {} as never, source: { - data: sortableItemData({ type: 'item', itemId: '123' }), + data: sortableItemData({ type: 'item', itemId: '123', rootElement: root }), element: document.createElement('article'), } as never, })).toBe(false); }); it('does not accept drops from another sortable lists root', () => { - const sourceRoot = document.createElement('div'); const targetRoot = document.createElement('div'); - const sourceElement = document.createElement('article'); + const foreignRoot = document.createElement('div'); const targetElement = document.createElement('article'); - sourceRoot.setAttribute('data-controller', 'sortable-lists'); - targetRoot.setAttribute('data-controller', 'sortable-lists'); - sourceRoot.append(sourceElement); - targetRoot.append(targetElement); - document.body.append(sourceRoot, targetRoot); - connectedControllerFor(targetElement); + connectedControllerFor(targetElement, { root: fakeRoot(targetRoot) }); expect(vi.mocked(dropTargetForElements).mock.lastCall?.[0].canDrop?.({ element: targetElement, input: {} as never, source: { - data: sortableItemData({ type: 'item', itemId: '456' }), - element: sourceElement, + data: sortableItemData({ type: 'item', itemId: '456', rootElement: foreignRoot }), + element: document.createElement('article'), } as never, })).toBe(false); - - sourceRoot.remove(); - targetRoot.remove(); }); it('does not accept drops whose type is rejected by the root', () => { const root = document.createElement('div'); - const sourceElement = document.createElement('article'); const targetElement = document.createElement('article'); - root.setAttribute('data-controller', 'sortable-lists'); - root.setAttribute('data-sortable-lists-accepted-type-value', 'work_package'); - root.append(sourceElement, targetElement); - document.body.append(root); - connectedControllerFor(targetElement); + connectedControllerFor(targetElement, { root: fakeRoot(root, { acceptedType: 'work_package' }) }); expect(vi.mocked(dropTargetForElements).mock.lastCall?.[0].canDrop?.({ element: targetElement, input: {} as never, source: { - data: sortableItemData({ type: 'meeting_agenda_item', itemId: '456' }), - element: sourceElement, + data: sortableItemData({ type: 'meeting_agenda_item', itemId: '456', rootElement: root }), + element: document.createElement('article'), } as never, })).toBe(false); + }); + + it('does not accept drops while the root is moving another item', () => { + const root = document.createElement('div'); + const targetElement = document.createElement('article'); + + connectedControllerFor(targetElement, { root: fakeRoot(root, { moving: true }) }); - root.remove(); + expect(vi.mocked(dropTargetForElements).mock.lastCall?.[0].canDrop?.({ + element: targetElement, + input: {} as never, + source: { + data: sortableItemData({ type: 'item', itemId: '456', rootElement: root }), + element: document.createElement('article'), + } as never, + })).toBe(false); }); it('does not expose native external drag data', () => { @@ -354,26 +368,39 @@ describe('Sortable lists item controller', () => { element.remove(); }); - it('does not start dragging while the sortable lists root is moving another item', () => { - const root = document.createElement('div'); + it('does not start dragging while the root is moving another item', () => { const element = document.createElement('article'); const text = document.createElement('span'); + element.appendChild(text); + vi.spyOn(document, 'elementFromPoint').mockReturnValue(text); + + connectedControllerFor(element, { root: fakeRoot(document.createElement('div'), { moving: true }) }); + + expect(vi.mocked(draggable).mock.lastCall?.[0].canDrag?.({ + element, dragHandle: null, input: { clientX: 10, clientY: 10 } as never, + })).toBe(false); + }); - root.setAttribute('data-sortable-lists-moving', 'true'); - root.setAttribute('data-controller', 'sortable-lists'); - root.appendChild(element); + it('refuses to drag before the root reference is connected', () => { + const element = document.createElement('article'); + const text = document.createElement('span'); element.appendChild(text); - document.body.appendChild(root); vi.spyOn(document, 'elementFromPoint').mockReturnValue(text); - connectedControllerFor(element); + + connectedControllerFor(element, { root: null }); expect(vi.mocked(draggable).mock.lastCall?.[0].canDrag?.({ - element, - dragHandle: null, - input: { clientX: 10, clientY: 10 } as never, + element, dragHandle: null, input: { clientX: 10, clientY: 10 } as never, })).toBe(false); + }); + + it('includes the root element in the drag payload', () => { + const root = document.createElement('div'); + const element = document.createElement('article'); + connectedControllerFor(element, { root: fakeRoot(root) }); - root.remove(); + expect(vi.mocked(draggable).mock.lastCall?.[0].getInitialData?.(draggableArgs(element))) + .toEqual(expect.objectContaining({ itemId: '123', type: 'item', rootElement: root })); }); describe('Stimulus application wiring', () => { diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index 39fbed5437d3..c9379203859e 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -43,12 +43,9 @@ import { isSortableItemData, sortableItemData, type SortableItemData, + type SortableListsRoot, } from './drag-and-drop'; -import { - sortableItemSelector, - sortableListsMovingAttribute, - sortableListsRootSelector, -} from './list-dom'; +import { sortableItemSelector } from './list-dom'; type CleanupFn = () => void; @@ -91,6 +88,7 @@ export default class ItemController extends Controller { private cleanupFn?:CleanupFn; private dropIndicatorElement?:HTMLElement; + private root?:SortableListsRoot; connect() { this.cleanupFn = combine( @@ -104,11 +102,28 @@ export default class ItemController extends Controller { this.cleanupFn = undefined; } + connectRoot(root:SortableListsRoot):void { + this.root = root; + } + + disconnectRoot():void { + this.root = undefined; + } + + private get initialized():boolean { + return this.root != null; + } + private registerDraggable():CleanupFn { return draggable({ element: this.element, ...(this.hasHandleTarget ? { dragHandle: this.handleTarget } : {}), - canDrag: ({ input }) => this.canDragFromPoint(input.clientX, input.clientY), + canDrag: ({ input }) => { + if (!this.initialized || this.root!.moving) { + return false; + } + return this.canDragFromPoint(input.clientX, input.clientY); + }, getInitialData: () => this.getItemData(), onDragStart: () => { preventUnhandled.start(); @@ -140,15 +155,21 @@ export default class ItemController extends Controller { return dropTargetForElements({ element: this.element, canDrop: ({ source }) => { - if (!isSortableItemData(source.data) || source.data.itemId === this.idValue) { + if ( + !this.initialized || + this.root!.moving || + !isSortableItemData(source.data) || + source.data.itemId === this.idValue + ) { return false; } - const targetRoot = this.element.closest(sortableListsRootSelector); - const sourceRoot = source.element.closest(sortableListsRootSelector); + if (source.data.rootElement == null || source.data.rootElement !== this.root!.element) { + return false; + } - return targetRoot === sourceRoot && acceptsSortableItemType({ - acceptedType: targetRoot?.getAttribute('data-sortable-lists-accepted-type-value') ?? null, + return acceptsSortableItemType({ + acceptedType: this.root!.acceptedType, type: source.data.type, }); }, @@ -178,10 +199,6 @@ export default class ItemController extends Controller { } private canDragFromPoint(clientX:number, clientY:number):boolean { - if (this.element.closest(sortableListsRootSelector)?.hasAttribute(sortableListsMovingAttribute)) { - return false; - } - const target = this.element.ownerDocument.elementFromPoint(clientX, clientY); if (!(target instanceof Element) || !this.element.contains(target)) { @@ -197,6 +214,7 @@ export default class ItemController extends Controller { return sortableItemData({ itemId: this.idValue, type: this.typeValue, + rootElement: this.root?.element ?? null, }); } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts index 7edb4482e2b1..a78d6b638f71 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts @@ -27,8 +27,8 @@ //++ // Sortable lists use a DOM contract shared by the root and item controllers: -// the root has data-controller~="sortable-lists"; lists are root targets with -// data-sortable-lists-list-* metadata; items expose sortable-lists--item values; +// the root has data-controller~="sortable-lists"; lists are sortable-lists--list +// controllers wired to the root via outlets; items expose sortable-lists--item values; // sparse non-item rows may expose data-sortable-lists-prev-item-id. // // This module holds the drag-and-drop-agnostic half of that contract: reading @@ -37,9 +37,7 @@ export const sortableListsMovingAttribute = 'data-sortable-lists-moving'; export const sortableListsRootSelector = '[data-controller~="sortable-lists"]'; export const sortableItemSelector = '[data-sortable-lists--item-id-value]'; -export const sortableListSelector = '[data-sortable-lists-target~="list"]'; -export const sortableListTypeAttribute = 'data-sortable-lists-list-type'; -export const sortableListIdAttribute = 'data-sortable-lists-list-id'; +export const sortableListSelector = '[data-controller~="sortable-lists--list"]'; export const sortablePreviousItemIdAttribute = 'data-sortable-lists-prev-item-id'; // Rows can sit directly under the list element or inside a nested
                                              . diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts new file mode 100644 index 000000000000..e3af938b2ca3 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts @@ -0,0 +1,190 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import type { dropTargetForElements as dropTargetForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; +import type ListControllerType from './list.controller'; +import type { sortableItemData as sortableItemDataFn, SortableListsRoot } from './drag-and-drop'; + +// The list controller is tested in ISOLATION: the real outlet hand-over from the +// root is not implemented until Task 3, so here we render only the list, let it +// connect, then call connectRoot(fakeRoot) ourselves. The genuine outlet wiring +// is covered by Task 3's integration test. +describe('Sortable lists list controller', () => { + let dropTargetForElements:typeof dropTargetForElementsFn; + let ListController:typeof ListControllerType; + let sortableItemData:typeof sortableItemDataFn; + + let ctx:StimulusTestContext; + let fixture:HTMLElement; + + beforeAll(async () => { + vi.doMock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({ + draggable: vi.fn(() => vi.fn()), + dropTargetForElements: vi.fn(() => vi.fn()), + monitorForElements: vi.fn(() => vi.fn()), + })); + + ({ dropTargetForElements } = await import('@atlaskit/pragmatic-drag-and-drop/element/adapter')); + ({ default: ListController } = await import('./list.controller')); + ({ sortableItemData } = await import('./drag-and-drop')); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + ctx = await setupStimulusTest({ + controllers: { + 'sortable-lists--list': ListController, + }, + }); + fixture = ctx.container; + }); + + afterEach(() => ctx.dispose()); + + function fakeRoot( + element = document.createElement('div'), + { moving = false, acceptedType = null as string|null } = {}, + ):SortableListsRoot { + return { element, moving, acceptedType }; + } + + async function connectedListFor({ + type = 'sprint', + id = '7', + root = fakeRoot(), + }:{ + type?:string|null; + id?:string|null; + root?:SortableListsRoot|null; + } = {}) { + fixture.innerHTML = ` +
                                                + `; + const list = fixture.querySelector('[data-controller~="sortable-lists--list"]')!; + await ctx.nextFrame(); + + const controller = ctx.application.getControllerForElementAndIdentifier(list, 'sortable-lists--list') as unknown as InstanceType; + if (root) { + controller.connectRoot(root); + } + + return { list, controller }; + } + + function dropTargetOptionsFor(element:HTMLElement) { + return vi.mocked(dropTargetForElements).mock.calls.find(([options]) => options.element === element)?.[0]; + } + + function source(rootElement:HTMLElement|null, type = 'work_package') { + return { + data: sortableItemData({ itemId: '1', type, rootElement }), + element: document.createElement('li'), + } as never; + } + + it('registers itself as a drop target', async () => { + const { list } = await connectedListFor(); + + expect(dropTargetForElements).toHaveBeenCalledWith(expect.objectContaining({ element: list })); + }); + + it('does not register without a list type value', async () => { + const { list } = await connectedListFor({ type: null, root: null }); + + expect(dropTargetForElements).not.toHaveBeenCalledWith(expect.objectContaining({ element: list })); + }); + + it('exposes its list payload through getData', async () => { + const { list } = await connectedListFor({ type: 'sprint', id: '7' }); + + expect(dropTargetOptionsFor(list)?.getData?.({ element: list, input: {} as never, source: source(null) })) + .toEqual(expect.objectContaining({ type: 'sprint', listId: '7' })); + }); + + it('accepts a same-root item of the accepted type', async () => { + const root = document.createElement('div'); + const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package' }) }); + + expect(dropTargetOptionsFor(list)?.canDrop?.({ element: list, input: {} as never, source: source(root, 'work_package') })) + .toBe(true); + }); + + it('rejects an item whose type is not accepted', async () => { + const root = document.createElement('div'); + const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package' }) }); + + expect(dropTargetOptionsFor(list)?.canDrop?.({ element: list, input: {} as never, source: source(root, 'meeting_agenda_item') })) + .toBe(false); + }); + + it('rejects a source from another root', async () => { + const { list } = await connectedListFor({ root: fakeRoot(document.createElement('div')) }); + + expect(dropTargetOptionsFor(list)?.canDrop?.({ element: list, input: {} as never, source: source(document.createElement('div'), 'work_package') })) + .toBe(false); + }); + + it('rejects a source with no root reference', async () => { + const { list } = await connectedListFor({ root: fakeRoot(document.createElement('div')) }); + + expect(dropTargetOptionsFor(list)?.canDrop?.({ element: list, input: {} as never, source: source(null, 'work_package') })) + .toBe(false); + }); + + it('refuses drops until the root reference is connected', async () => { + const { list } = await connectedListFor({ root: null }); + + expect(dropTargetOptionsFor(list)?.canDrop?.({ element: list, input: {} as never, source: source(document.createElement('div'), 'work_package') })) + .toBe(false); + }); + + it('reflects the root moving state as aria-busy on connect', async () => { + const { list } = await connectedListFor({ root: fakeRoot(document.createElement('div'), { moving: true }) }); + + expect(list.getAttribute('aria-busy')).toEqual('true'); + }); + + it('clears aria-busy when reflectMoving is turned off', async () => { + const { list, controller } = await connectedListFor({ root: fakeRoot(document.createElement('div'), { moving: true }) }); + expect(list.getAttribute('aria-busy')).toEqual('true'); + + controller.reflectMoving(false); + expect(list.hasAttribute('aria-busy')).toBe(false); + }); + + it('rejects drops while the root is moving', async () => { + const root = document.createElement('div'); + const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package', moving: true }) }); + + expect(dropTargetOptionsFor(list)?.canDrop?.({ element: list, input: {} as never, source: source(root, 'work_package') })) + .toBe(false); + }); +}); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts new file mode 100644 index 000000000000..d20e18eafc29 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts @@ -0,0 +1,122 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { Controller } from '@hotwired/stimulus'; +import { + acceptsSortableItemType, + isSortableItemData, + sortableListData, + type SortableListData, + type SortableListsRoot, +} from './drag-and-drop'; + +type CleanupFn = () => void; + +export default class ListController extends Controller { + static values = { + type: String, + id: String, + }; + + declare readonly typeValue:string; + declare readonly hasTypeValue:boolean; + declare readonly idValue:string; + declare readonly hasIdValue:boolean; + + private root?:SortableListsRoot; + private cleanupFn?:CleanupFn; + + connect():void { + // A list without a type value is not a drop target. + if (!this.hasTypeValue) { + return; + } + + this.cleanupFn = dropTargetForElements({ + element: this.element, + canDrop: ({ source }) => this.canDrop(source.data), + getData: () => this.listData, + getIsSticky: () => false, + }); + } + + disconnect():void { + this.cleanupFn?.(); + this.cleanupFn = undefined; + this.root = undefined; + } + + // Called by the root controller's outlet-connected callback. + connectRoot(root:SortableListsRoot):void { + this.root = root; + this.reflectMoving(root.moving); + } + + disconnectRoot():void { + this.root = undefined; + } + + reflectMoving(moving:boolean):void { + if (moving) { + this.element.setAttribute('aria-busy', 'true'); + } else { + this.element.removeAttribute('aria-busy'); + } + } + + private get initialized():boolean { + return this.root != null; + } + + private get listData():SortableListData { + return sortableListData({ + type: this.typeValue, + listId: this.hasIdValue ? this.idValue : null, + }); + } + + private canDrop(data:Record):boolean { + if (!this.initialized || this.root!.moving) { + return false; + } + + if (!isSortableItemData(data)) { + return false; + } + + if (data.rootElement == null || data.rootElement !== this.root!.element) { + return false; + } + + return acceptsSortableItemType({ + acceptedType: this.root!.acceptedType, + type: data.type, + }); + } +} diff --git a/modules/backlogs/app/components/backlogs/inbox_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_component.html.erb index 5fdc491197e6..30cb7315744f 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb @@ -39,8 +39,8 @@ See COPYRIGHT and LICENSE files for more details. header_padding: :default, test_selector: "backlog-inbox", data: { - sortable_lists_target: "list", - sortable_lists_list_type: "inbox" + controller: "sortable-lists--list", + sortable_lists__list_type_value: "inbox" } ) ) do |list| diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb index e84ad86aa286..b2cd49e80c14 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb @@ -116,10 +116,10 @@ def merge_drag_and_drop_data! def drag_and_drop_data data = { - sortable_lists_target: "list", - sortable_lists_list_type: drag_and_drop.fetch(:list_type) + controller: "sortable-lists--list", + sortable_lists__list_type_value: drag_and_drop.fetch(:list_type) } - data[:sortable_lists_list_id] = drag_and_drop[:list_id] if drag_and_drop[:list_id].present? + data[:sortable_lists__list_id_value] = drag_and_drop[:list_id] if drag_and_drop[:list_id].present? data end diff --git a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb index c34fe48f31f2..c0302f2cfcee 100644 --- a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb @@ -33,7 +33,9 @@ See COPYRIGHT and LICENSE files for more details. data: { controller: "backlogs--list-refresh sortable-lists", sortable_lists_accepted_type_value: "work_package", - sortable_lists_move_url_template_value: backlogs_move_url_template(@project) + sortable_lists_move_url_template_value: backlogs_move_url_template(@project), + sortable_lists_sortable_lists__list_outlet: "#backlogs_container [data-controller~='sortable-lists--list']", + sortable_lists_sortable_lists__item_outlet: "#backlogs_container [data-controller~='sortable-lists--item']" } do %>
                                                diff --git a/modules/backlogs/app/views/backlogs/backlog/show.html.erb b/modules/backlogs/app/views/backlogs/backlog/show.html.erb index b7da4440385f..82831ed13fb4 100644 --- a/modules/backlogs/app/views/backlogs/backlog/show.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/show.html.erb @@ -50,7 +50,9 @@ See COPYRIGHT and LICENSE files for more details. data: { controller: "backlogs--list-refresh sortable-lists", sortable_lists_accepted_type_value: "work_package", - sortable_lists_move_url_template_value: backlogs_move_url_template(@project) + sortable_lists_move_url_template_value: backlogs_move_url_template(@project), + sortable_lists_sortable_lists__list_outlet: "#backlogs_container [data-controller~='sortable-lists--list']", + sortable_lists_sortable_lists__item_outlet: "#backlogs_container [data-controller~='sortable-lists--item']" } %> <% end %> diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index 0e0a7628d50b..022adc848b45 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -118,11 +118,11 @@ def render_component expect(rendered_component).to have_css(".sr-only", text: "3 story points") end - it "wires the bucket drop-target data on the box" do + it "wires the list controller and value attributes for the bucket" do expect(rendered_component).to have_css(".Box") do |box| - expect(box["data-sortable-lists-target"]).to eq("list") - expect(box["data-sortable-lists-list-type"]).to eq("backlog_bucket") - expect(box["data-sortable-lists-list-id"]).to eq(backlog_bucket.id.to_s) + expect(box["data-controller"]).to include("sortable-lists--list") + expect(box["data-sortable-lists--list-type-value"]).to eq("backlog_bucket") + expect(box["data-sortable-lists--list-id-value"]).to eq(backlog_bucket.id.to_s) end end diff --git a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb index 9b7acf61dea0..784ec046450b 100644 --- a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb @@ -64,11 +64,11 @@ def render_component expect(page).to have_css(".Box#inbox_project_#{project.id}") end - it "wires drop-target data attributes for the inbox" do + it "wires the list controller for the inbox" do expect(page).to have_css(".Box#inbox_project_#{project.id}") do |box| - expect(box["data-sortable-lists-target"]).to eq("list") - expect(box["data-sortable-lists-list-type"]).to eq("inbox") - expect(box["data-sortable-lists-list-id"]).to be_nil + expect(box["data-controller"]).to include("sortable-lists--list") + expect(box["data-sortable-lists--list-type-value"]).to eq("inbox") + expect(box["data-sortable-lists--list-id-value"]).to be_nil end end diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index 8d2ec25df525..99ebb41a1e56 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -101,11 +101,11 @@ def menu_items expect(rendered_component).to have_text(work_package2.subject) end - it "wires drop-target data attributes for the sprint" do + it "wires the list controller and value attributes for the sprint" do expect(rendered_component).to have_css(".Box") do |box| - expect(box["data-sortable-lists-target"]).to eq("list") - expect(box["data-sortable-lists-list-type"]).to eq("sprint") - expect(box["data-sortable-lists-list-id"]).to eq(sprint.id.to_s) + expect(box["data-controller"]).to include("sortable-lists--list") + expect(box["data-sortable-lists--list-type-value"]).to eq("sprint") + expect(box["data-sortable-lists--list-id-value"]).to eq(sprint.id.to_s) end end diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb index f0893040e4dc..8361e9be7b54 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb @@ -276,9 +276,9 @@ def render_component(work_packages:, container:, drag_and_drop:) describe "drag-and-drop data merging" do context "without drag_and_drop" do it "does not emit drag-and-drop data" do - expect(rendered_component).to have_no_css(".Box[data-sortable-lists-target]") - expect(rendered_component).to have_no_css(".Box[data-sortable-lists-list-type]") - expect(rendered_component).to have_no_css(".Box[data-sortable-lists-list-id]") + expect(rendered_component).to have_no_css(".Box[data-controller~='sortable-lists--list']") + expect(rendered_component).to have_no_css(".Box[data-sortable-lists--list-type-value]") + expect(rendered_component).to have_no_css(".Box[data-sortable-lists--list-id-value]") end end @@ -287,11 +287,11 @@ def render_component(work_packages:, container:, drag_and_drop:) { list_type: "sprint", list_id: sprint.id } end - it "merges drag-and-drop data attributes onto the box" do + it "wires the list controller and value attributes onto the box" do expect(rendered_component).to have_css(".Box") do |box| - expect(box["data-sortable-lists-target"]).to eq("list") - expect(box["data-sortable-lists-list-type"]).to eq("sprint") - expect(box["data-sortable-lists-list-id"]).to eq(sprint.id.to_s) + expect(box["data-controller"]).to include("sortable-lists--list") + expect(box["data-sortable-lists--list-type-value"]).to eq("sprint") + expect(box["data-sortable-lists--list-id-value"]).to eq(sprint.id.to_s) end end end diff --git a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb index bbd5d3cd3ef3..57933834ec06 100644 --- a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb +++ b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb @@ -92,6 +92,14 @@ expect(response.body).to include( %(data-sortable-lists-move-url-template-value="/projects/#{project.identifier}/backlogs/work_packages/{id}/move") ) + expect(response.body).to include( + "data-sortable-lists-sortable-lists--list-outlet=" \ + "\"#backlogs_container [data-controller~='sortable-lists--list']\"" + ) + expect(response.body).to include( + "data-sortable-lists-sortable-lists--item-outlet=" \ + "\"#backlogs_container [data-controller~='sortable-lists--item']\"" + ) expect(response.body).to include('id="owner_backlogs_container"') expect(response.body).to include('id="sprint_backlogs_container"') expect(response.body.scan('data-sortable-lists-target="scrollable"').size).to eq(2) @@ -133,9 +141,9 @@ expect(response).to have_http_status(:ok) expect(response.body).to include(%(id="inbox_project_#{project.id}")) - expect(response.body).to include('data-sortable-lists-target="list"') - expect(response.body).to include('data-sortable-lists-list-type="inbox"') - expect(response.body).not_to include('data-sortable-lists-list-id="inbox"') + expect(response.body).to include('data-controller="sortable-lists--list"') + expect(response.body).to include('data-sortable-lists--list-type-value="inbox"') + expect(response.body).not_to include('data-sortable-lists--list-id-value="inbox"') end context "with backlog buckets" do @@ -146,8 +154,8 @@ expect(response).to have_http_status(:ok) expect(response.body).to include(%(data-test-selector="backlog-bucket-#{backlog_bucket.id}")) - expect(response.body).to include('data-sortable-lists-list-type="backlog_bucket"') - expect(response.body).to include(%(data-sortable-lists-list-id="#{backlog_bucket.id}")) + expect(response.body).to include('data-sortable-lists--list-type-value="backlog_bucket"') + expect(response.body).to include(%(data-sortable-lists--list-id-value="#{backlog_bucket.id}")) end end end From b9582a29be55e8a506039f58c06fe2828a7bd640 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 17 Jun 2026 21:53:16 +0200 Subject: [PATCH 055/105] Harden sortable-lists DnD and document contract Drag monitoring and auto-scroll are now scoped to the owning root so coexisting roots no longer react to each other's drags. A list clears its busy state when it disconnects mid-move, and an item warns when it is wired without the id or type it needs to be moved. A shared accept rule and explicit root and child interfaces replace the duplicated checks, and the wiring contract is documented in CONTEXT.md. --- frontend/CONTEXT.md | 45 ++++++++++++++++++ .../dynamic/sortable-lists.controller.spec.ts | 25 +++++++++- .../dynamic/sortable-lists.controller.ts | 10 ++-- .../dynamic/sortable-lists/drag-and-drop.ts | 22 +++++++++ .../sortable-lists/item.controller.spec.ts | 36 ++++++++++++++ .../dynamic/sortable-lists/item.controller.ts | 47 ++++++++++++------- .../sortable-lists/list.controller.spec.ts | 25 ++++++++-- .../dynamic/sortable-lists/list.controller.ts | 24 ++++------ 8 files changed, 191 insertions(+), 43 deletions(-) diff --git a/frontend/CONTEXT.md b/frontend/CONTEXT.md index e93fe8b569bb..7638cdbd337b 100644 --- a/frontend/CONTEXT.md +++ b/frontend/CONTEXT.md @@ -49,6 +49,51 @@ _Avoid_: Container - A **Sortable item** has a **Sortable item type**. - A **Sortable list** may accept only specific **Sortable item types**. +## Sortable lists wiring (consumer contract) + +The sortable-lists drag-and-drop subsystem is three cooperating Stimulus +controllers. A consumer wires them through values, outlets, targets, and a fixed +DOM shape. This contract is OpenProject-internal (it relies on the app's +`FetchRequest`, toaster, `I18n`, and Primer conventions); it is reusable across +OpenProject features, not as a standalone library. + +**Root — `sortable-lists`** (the orchestrator; owns the move request, the drag +monitor, and auto-scroll): +- `accepted-type-value` (optional): the one **Sortable item type** every list + under this root accepts. Root-scoped — there is one accepted type per root, + shared by all its lists. Omit to accept any type. +- `move-url-template-value`: a URI template expanded with `{id}` (the moved + item's id) to build the PUT URL. Without it, no move is persisted. +- `allowed-axis-value` (default `vertical`), `max-scroll-speed-value` + (default `standard`): auto-scroll tuning. +- Outlets `sortable-lists--list` and `sortable-lists--item`: point at the + descendant lists and items so the root can hand each its reference. +- Target `scrollable`: each element that should auto-scroll during a drag. + +**List — `sortable-lists--list`** (a **Drop target**): +- `type-value` (**required** to be a drop target; a list without it is inert). +- `id-value` (optional): the list id sent as `list_id`. + +**Item — `sortable-lists--item`** (a **Sortable item**; the drag source and edge +indicator owner): +- `id-value` (**required**): the item id; sent as the `{id}` URL segment. +- `type-value` (**required**): the item's **Sortable item type**, matched + against the root's `accepted-type-value`. There is no default — an item that + omits id or type appears draggable but silently refuses every drop, so the + controller `console.warn`s on connect when either is empty. +- Target `handle` (optional): restricts the drag to a handle element. +- Target `preview` (optional): the element cloned for the **Drag preview**. + +**DOM shape (required):** rows are `
                                              • ` elements, sitting either directly under +the list element or inside a single nested `
                                                  `. A non-item row (e.g. a +truncated "show more" marker) may carry `data-sortable-lists-prev-item-id` so +position resolution stays correct in sparse lists. + +**Move wire:** a successful drop PUTs `FormData` (`list_type`, `list_id`, +`prev_id`) to the expanded move URL; the server replies with a turbo-stream. A +422 is treated as a handled validation response (no toast); other failures roll +the row back and toast. + ## Example Dialogue > **Dev:** "What should we call the floating visual during a drag?" diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 602aaa8d5996..3027c4935aec 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -417,6 +417,23 @@ describe('Sortable lists controller', () => { await flushPromises(); }); + it('only monitors drags belonging to its own root', async () => { + const { root, firstSourceItem } = renderFixture(); + + await ctx.nextFrame(); + + const canMonitor = vi.mocked(monitorForElements).mock.lastCall?.[0].canMonitor; + + expect(canMonitor?.({ + source: sourcePayload(firstSourceItem, sortableItemData({ itemId: '1', type: 'work_package', rootElement: root })), + initial: {} as never, + })).toBe(true); + expect(canMonitor?.({ + source: sourcePayload(firstSourceItem, sortableItemData({ itemId: '1', type: 'work_package', rootElement: document.createElement('div') })), + initial: {} as never, + })).toBe(false); + }); + it('dispatches an error toast when the move request rejects', async () => { const toastEvents:CustomEvent[] = []; const onToast = (event:Event) => toastEvents.push(event as CustomEvent); @@ -525,6 +542,7 @@ describe('Sortable lists controller', () => { it('registers scrollable targets for vertical sortable list auto-scrolling', async () => { const scrollable = renderScrollableFixture(); + const root = fixture.querySelector('[data-controller~="sortable-lists"]')!; await ctx.nextFrame(); @@ -537,13 +555,18 @@ describe('Sortable lists controller', () => { expect(options?.canScroll?.({ element: scrollable, input: input(), - source: sourcePayload(itemRow('1')), + source: sourcePayload(itemRow('1'), sortableItemData({ itemId: '1', type: 'work_package', rootElement: root })), })).toBe(true); expect(options?.canScroll?.({ element: scrollable, input: input(), source: sourcePayload(document.createElement('div'), { type: 'unrelated' }), })).toBe(false); + expect(options?.canScroll?.({ + element: scrollable, + input: input(), + source: sourcePayload(itemRow('1'), sortableItemData({ itemId: '1', type: 'work_package', rootElement: document.createElement('div') })), + })).toBe(false); expect(options?.getAllowedAxis?.({ element: scrollable, input: input(), diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 41197bcde6fc..27b263cf4262 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -40,6 +40,7 @@ import { buildMoveFormData, isSortableItemData, resolveDropIntent, + type RootAwareChild, type SortableListData, type SortableListsRoot, } from './sortable-lists/drag-and-drop'; @@ -55,12 +56,11 @@ type ElementDropPayload = ElementEventPayloadMap['onDrop']; type AutoScrollAllowedAxis = 'vertical'|'horizontal'|'all'; type AutoScrollMaxScrollSpeed = 'standard'|'fast'; type MoveResult = { ok:true }|{ ok:false; showToast:boolean }; -interface RootAwareChild { connectRoot(root:SortableListsRoot):void; disconnectRoot():void } const allowedAxes = new Set(['vertical', 'horizontal', 'all']); const maxScrollSpeeds = new Set(['standard', 'fast']); -export default class SortableListsController extends Controller { +export default class SortableListsController extends Controller implements SortableListsRoot { static targets = ['scrollable']; static outlets = ['sortable-lists--list', 'sortable-lists--item']; @@ -87,7 +87,9 @@ export default class SortableListsController extends Controller { connect():void { this.monitorCleanupFn = monitorForElements({ - canMonitor: ({ source }) => !this.moving && isSortableItemData(source.data), + canMonitor: ({ source }) => !this.moving + && isSortableItemData(source.data) + && source.data.rootElement === this.element, onDrop: (args) => { void this.handleDrop(args); }, @@ -120,7 +122,7 @@ export default class SortableListsController extends Controller { scrollableTargetConnected(element:HTMLElement):void { const cleanup = autoScrollForElements({ element, - canScroll: ({ source }) => isSortableItemData(source.data), + canScroll: ({ source }) => isSortableItemData(source.data) && source.data.rootElement === this.element, getAllowedAxis: () => this.allowedAxis, getConfiguration: () => ({ maxScrollSpeed: this.maxScrollSpeed }), }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index aebd305f55e1..e1565da0c4df 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -66,6 +66,13 @@ export interface SortableListsRoot { readonly acceptedType:string|null; } +// Implemented by the list and item controllers so the root can hand them its +// reference (and revoke it) through outlet-connected callbacks. +export interface RootAwareChild { + connectRoot(root:SortableListsRoot):void; + disconnectRoot():void; +} + export function isSortableItemData(data:Record):data is SortableItemData { return data[sortableItemDataKey] === true && typeof data.type === 'string' @@ -140,6 +147,21 @@ export function acceptsSortableItemType({ return acceptedType === null || acceptedType === type; } +// The drop rule shared by list and item drop targets: the payload must be a +// sortable item belonging to this same root and of an accepted type. Item +// targets additionally exclude themselves before calling this. +export function canAccept(root:SortableListsRoot, data:Record):boolean { + if (!isSortableItemData(data)) { + return false; + } + + if (data.rootElement == null || data.rootElement !== root.element) { + return false; + } + + return acceptsSortableItemType({ acceptedType: root.acceptedType, type: data.type }); +} + export function isSourceListTarget({ sourceElement, targetElement, diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index 4f684e561cd2..93bff5d62993 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -103,7 +103,9 @@ describe('Sortable lists item controller', () => { Object.defineProperty(controller, 'element', { value: element }); Object.defineProperty(controller, 'idValue', { value: '123' }); + Object.defineProperty(controller, 'hasIdValue', { value: true }); Object.defineProperty(controller, 'typeValue', { value: 'item' }); + Object.defineProperty(controller, 'hasTypeValue', { value: true }); Object.defineProperty(controller, 'hasHandleTarget', { value: handle !== null }); if (handle) { Object.defineProperty(controller, 'handleTarget', { value: handle }); @@ -147,6 +149,40 @@ describe('Sortable lists item controller', () => { }); }); + function connectItem({ id, type }:{ id:string; type:string }) { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const controller = Object.create(ItemController.prototype) as InstanceType; + + Object.defineProperty(controller, 'element', { value: document.createElement('li') }); + Object.defineProperty(controller, 'idValue', { value: id }); + Object.defineProperty(controller, 'hasIdValue', { value: id !== '' }); + Object.defineProperty(controller, 'typeValue', { value: type }); + Object.defineProperty(controller, 'hasTypeValue', { value: type !== '' }); + Object.defineProperty(controller, 'hasHandleTarget', { value: false }); + + controller.connect(); + + return warn; + } + + it('warns when connected without an item id', () => { + const warn = connectItem({ id: '', type: 'work_package' }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('id'), expect.anything()); + }); + + it('warns when connected without an item type', () => { + const warn = connectItem({ id: '123', type: '' }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('type'), expect.anything()); + }); + + it('does not warn when id and type are both present', () => { + const warn = connectItem({ id: '123', type: 'work_package' }); + + expect(warn).not.toHaveBeenCalled(); + }); + it('marks the closest edge while dragging over an item', () => { const element = document.createElement('article'); const controller = controllerFor(element); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index c9379203859e..3862f83c36ec 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -39,9 +39,10 @@ import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unha import { Controller } from '@hotwired/stimulus'; import { closestInteractiveElement } from 'core-stimulus/helpers/interactive-element-helper'; import { - acceptsSortableItemType, + canAccept, isSortableItemData, sortableItemData, + type RootAwareChild, type SortableItemData, type SortableListsRoot, } from './drag-and-drop'; @@ -70,16 +71,18 @@ const PREVIEW_STRIPPED_ATTRIBUTES = [ // `.Box--condensed .Box-card`) would not apply to it otherwise. const BOX_DENSITY_VARIANT_CLASSES = ['Box--condensed', 'Box--spacious'] as const; -export default class ItemController extends Controller { +export default class ItemController extends Controller implements RootAwareChild { static targets = ['handle', 'preview']; static values = { id: String, - type: { type: String, default: 'item' }, + type: String, }; - declare idValue:string; - declare typeValue:string; + declare readonly idValue:string; + declare readonly hasIdValue:boolean; + declare readonly typeValue:string; + declare readonly hasTypeValue:boolean; declare readonly handleTarget:HTMLElement; declare readonly hasHandleTarget:boolean; @@ -90,18 +93,21 @@ export default class ItemController extends Controller { private dropIndicatorElement?:HTMLElement; private root?:SortableListsRoot; - connect() { + connect():void { + this.warnOnMissingValues(); this.cleanupFn = combine( this.registerDraggable(), this.registerDropTarget(), ); } - disconnect() { + disconnect():void { this.cleanupFn?.(); this.cleanupFn = undefined; + this.disconnectRoot(); } + // Called by the root controller's outlet-connected callback. connectRoot(root:SortableListsRoot):void { this.root = root; } @@ -114,6 +120,19 @@ export default class ItemController extends Controller { return this.root != null; } + // Both values are required: an item with an empty id can never be persisted, + // and an empty type never matches the root's accepted type, so the item would + // appear draggable yet silently refuse every drop. Surface that wiring mistake. + private warnOnMissingValues():void { + if (!this.hasIdValue) { + console.warn('sortable-lists--item is missing its required id value (data-sortable-lists--item-id-value); it cannot be moved.', this.element); + } + + if (!this.hasTypeValue) { + console.warn('sortable-lists--item is missing its required type value (data-sortable-lists--item-type-value); it cannot be moved.', this.element); + } + } + private registerDraggable():CleanupFn { return draggable({ element: this.element, @@ -155,23 +174,15 @@ export default class ItemController extends Controller { return dropTargetForElements({ element: this.element, canDrop: ({ source }) => { - if ( - !this.initialized || - this.root!.moving || - !isSortableItemData(source.data) || - source.data.itemId === this.idValue - ) { + if (!this.initialized || this.root!.moving) { return false; } - if (source.data.rootElement == null || source.data.rootElement !== this.root!.element) { + if (isSortableItemData(source.data) && source.data.itemId === this.idValue) { return false; } - return acceptsSortableItemType({ - acceptedType: this.root!.acceptedType, - type: source.data.type, - }); + return canAccept(this.root!, source.data); }, getData: ({ input }) => { return attachClosestEdge(this.getItemData(), { diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts index e3af938b2ca3..e9f9626ae852 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts @@ -31,10 +31,10 @@ import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test- import type ListControllerType from './list.controller'; import type { sortableItemData as sortableItemDataFn, SortableListsRoot } from './drag-and-drop'; -// The list controller is tested in ISOLATION: the real outlet hand-over from the -// root is not implemented until Task 3, so here we render only the list, let it -// connect, then call connectRoot(fakeRoot) ourselves. The genuine outlet wiring -// is covered by Task 3's integration test. +// The list controller is tested in ISOLATION: the root drives the outlet +// hand-over in production (sortable-lists.controller.ts), so here we render only +// the list, let it connect, then call connectRoot(fakeRoot) ourselves to stand +// in for that wiring. describe('Sortable lists list controller', () => { let dropTargetForElements:typeof dropTargetForElementsFn; let ListController:typeof ListControllerType; @@ -180,6 +180,23 @@ describe('Sortable lists list controller', () => { expect(list.hasAttribute('aria-busy')).toBe(false); }); + it('clears aria-busy when the root outlet disconnects mid-move', async () => { + const { list, controller } = await connectedListFor({ root: fakeRoot(document.createElement('div'), { moving: true }) }); + expect(list.getAttribute('aria-busy')).toEqual('true'); + + controller.disconnectRoot(); + expect(list.hasAttribute('aria-busy')).toBe(false); + }); + + it('clears aria-busy when the list element disconnects mid-move', async () => { + const { list } = await connectedListFor({ root: fakeRoot(document.createElement('div'), { moving: true }) }); + expect(list.getAttribute('aria-busy')).toEqual('true'); + + list.remove(); + await ctx.nextFrame(); + expect(list.hasAttribute('aria-busy')).toBe(false); + }); + it('rejects drops while the root is moving', async () => { const root = document.createElement('div'); const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package', moving: true }) }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts index d20e18eafc29..7b2fbf295c8a 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts @@ -29,16 +29,16 @@ import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { Controller } from '@hotwired/stimulus'; import { - acceptsSortableItemType, - isSortableItemData, + canAccept, sortableListData, + type RootAwareChild, type SortableListData, type SortableListsRoot, } from './drag-and-drop'; type CleanupFn = () => void; -export default class ListController extends Controller { +export default class ListController extends Controller implements RootAwareChild { static values = { type: String, id: String, @@ -69,7 +69,7 @@ export default class ListController extends Controller { disconnect():void { this.cleanupFn?.(); this.cleanupFn = undefined; - this.root = undefined; + this.disconnectRoot(); } // Called by the root controller's outlet-connected callback. @@ -80,6 +80,9 @@ export default class ListController extends Controller { disconnectRoot():void { this.root = undefined; + // The root only reaches still-connected list outlets when it ends a move, so + // a list that disconnects mid-move would otherwise keep aria-busy forever. + this.reflectMoving(false); } reflectMoving(moving:boolean):void { @@ -106,17 +109,6 @@ export default class ListController extends Controller { return false; } - if (!isSortableItemData(data)) { - return false; - } - - if (data.rootElement == null || data.rootElement !== this.root!.element) { - return false; - } - - return acceptsSortableItemType({ - acceptedType: this.root!.acceptedType, - type: data.type, - }); + return canAccept(this.root!, data); } } From bd0ef0059cc1742d1bdb4a46e93630cccccb2e45 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 18 Jun 2026 00:10:59 +0200 Subject: [PATCH 056/105] Refine sortable-lists DnD morph and guards The Turbo morph guard now only preserves Pragmatic DnD markers on removal mutations, so a server-driven add or update of the same attribute is no longer frozen. The list and item drop guards read the root through a local binding and a null check instead of a non-null assertion behind an initialized getter, so the narrowing is sound and survives refactoring. --- .../dynamic/sortable-lists/item.controller.ts | 12 +++++------- .../dynamic/sortable-lists/list.controller.ts | 9 +++------ .../src/turbo/pragmatic-dnd-morph-attributes.spec.ts | 10 ++++++++++ frontend/src/turbo/pragmatic-dnd-morph-attributes.ts | 7 +++++++ 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index 3862f83c36ec..912ea7f9785f 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -116,10 +116,6 @@ export default class ItemController extends Controller implements R this.root = undefined; } - private get initialized():boolean { - return this.root != null; - } - // Both values are required: an item with an empty id can never be persisted, // and an empty type never matches the root's accepted type, so the item would // appear draggable yet silently refuse every drop. Surface that wiring mistake. @@ -138,7 +134,8 @@ export default class ItemController extends Controller implements R element: this.element, ...(this.hasHandleTarget ? { dragHandle: this.handleTarget } : {}), canDrag: ({ input }) => { - if (!this.initialized || this.root!.moving) { + const { root } = this; + if (root == null || root.moving) { return false; } return this.canDragFromPoint(input.clientX, input.clientY); @@ -174,7 +171,8 @@ export default class ItemController extends Controller implements R return dropTargetForElements({ element: this.element, canDrop: ({ source }) => { - if (!this.initialized || this.root!.moving) { + const { root } = this; + if (root == null || root.moving) { return false; } @@ -182,7 +180,7 @@ export default class ItemController extends Controller implements R return false; } - return canAccept(this.root!, source.data); + return canAccept(root, source.data); }, getData: ({ input }) => { return attachClosestEdge(this.getItemData(), { diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts index 7b2fbf295c8a..f0c920f695ea 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts @@ -93,10 +93,6 @@ export default class ListController extends Controller implements R } } - private get initialized():boolean { - return this.root != null; - } - private get listData():SortableListData { return sortableListData({ type: this.typeValue, @@ -105,10 +101,11 @@ export default class ListController extends Controller implements R } private canDrop(data:Record):boolean { - if (!this.initialized || this.root!.moving) { + const { root } = this; + if (root == null || root.moving) { return false; } - return canAccept(this.root!, data); + return canAccept(root, data); } } diff --git a/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts b/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts index de6e0387b5f1..0ae7f52cf943 100644 --- a/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts +++ b/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts @@ -76,6 +76,16 @@ describe('Pragmatic DnD morph attribute preservation', () => { expect(event.defaultPrevented).toBe(true); }); + it('lets marker attributes morph when the mutation is not a removal', () => { + const element = appendSortableRow(); + const event = beforeMorphAttributeEvent('data-dragging', 'updated'); + + element.setAttribute('data-dragging', 'source'); + element.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + }); + it('lets unrelated attributes continue morphing', () => { const element = appendSortableRow(); const event = beforeMorphAttributeEvent('class'); diff --git a/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts b/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts index fb68559111fe..2600448471f8 100644 --- a/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts +++ b/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts @@ -48,6 +48,13 @@ export function registerPragmaticDndMorphAttributePreservation():void { return; } + // Only protect removals: these markers are Pragmatic DnD-owned and absent + // from server HTML, so a morph strips them. A server-driven add/update of + // the same attribute should still be allowed through. + if (event.detail.mutationType !== 'remove') { + return; + } + if (!(event.target instanceof Element) || event.target.closest(sortableListsRootSelector) === null) { return; } From ac4c43346933a5a9b0d09f06faad8e6e371a6fd3 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 24 Jun 2026 23:00:48 +0200 Subject: [PATCH 057/105] Add configurable drop position for sortable lists Backlogs wants an item dropped onto another container to land at the top, not appended to the tail. Making the landing position a per-list option keeps the generic end-append default while letting Backlogs lists opt into prepending. --- .../sortable-lists/drag-and-drop.spec.ts | 23 +++++++++++++++ .../dynamic/sortable-lists/drag-and-drop.ts | 29 ++++++++++++++++++- .../sortable-lists/list.controller.spec.ts | 26 ++++++++++++++++- .../dynamic/sortable-lists/list.controller.ts | 10 +++++++ .../backlogs/bucket_component.html.erb | 3 +- .../backlogs/inbox_component.html.erb | 3 +- .../backlogs/sprint_component.html.erb | 3 +- .../work_package_card_list_component.rb | 1 + .../backlogs/bucket_component_spec.rb | 1 + .../backlogs/inbox_component_spec.rb | 1 + .../backlogs/sprint_component_spec.rb | 1 + 11 files changed, 96 insertions(+), 5 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts index aa34d0fbacdf..c1b1b50363bd 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -359,6 +359,29 @@ describe('sortable lists drag and drop helpers', () => { expect(intent?.previousItemId).toEqual('5'); }); + it('prepends to the list when its drop position is start', () => { + const { root, list } = buildList(); + const sourceList = document.createElement('ul'); + const source = itemRow('1'); + + sourceList.setAttribute('data-controller', 'sortable-lists--list'); + sourceList.append(source); + list.append(itemRow('4'), itemRow('5')); + root.append(sourceList); + + const intent = resolveDropIntent({ + location: dropLocation({ + dropTargets: [{ data: sortableListData({ type: 'backlog_bucket', listId: '7', dropPosition: 'start' }), element: list }], + }), + root, + sourceElement: source, + sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), + }); + + expect(intent?.listElement).toBe(list); + expect(intent?.previousItemId).toBeNull(); + }); + it('returns null for a drop back onto the source list without a target item', () => { const { root, list } = buildList(); const source = itemRow('1'); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index e1565da0c4df..baeb539ff5f4 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -51,10 +51,14 @@ export interface SortableItemData extends Record { rootElement:HTMLElement|null; } +export type SortableListDropPosition = 'start'|'end'; + export interface SortableListData extends Record { [sortableListDataKey]:true; type:string; listId:string|null; + // Where a list-only drop (header or empty space, not over an item) lands. + dropPosition:SortableListDropPosition; } // Implemented by the sortable-lists root controller and handed to list/item @@ -108,14 +112,17 @@ export function sortableItemData({ export function sortableListData({ type, listId, + dropPosition = 'end', }:{ type:string; listId:string|null; + dropPosition?:SortableListDropPosition; }):SortableListData { return { [sortableListDataKey]: true, type, listId, + dropPosition, }; } @@ -203,6 +210,25 @@ export function resolvePreviousSortableItemId({ return null; } +// A list-only drop (over the header or empty space, not over an item) lands at +// the position the target list declares: 'start' inserts before the first row +// (null previous item), 'end' appends after the last. +function resolveListOnlyPreviousItemId({ + sourceItemId, + list, + dropPosition, +}:{ + sourceItemId:string; + list:HTMLElement; + dropPosition:SortableListDropPosition; +}):string|null { + if (dropPosition === 'start') { + return null; + } + + return resolveListAppendPreviousItemId({ sourceItemId, list }); +} + export interface DropIntent { listElement:HTMLElement; listData:SortableListData; @@ -250,9 +276,10 @@ export function resolveDropIntent({ targetItem: targetItem.element, closestEdge: extractClosestEdge(targetItem.data), }) - : resolveListAppendPreviousItemId({ + : resolveListOnlyPreviousItemId({ sourceItemId: sourceData.itemId, list: listElement, + dropPosition: listData.dropPosition, }); return { listElement, listData, previousItemId }; diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts index e9f9626ae852..f63535c09af0 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts @@ -77,16 +77,19 @@ describe('Sortable lists list controller', () => { async function connectedListFor({ type = 'sprint', id = '7', + dropPosition = null, root = fakeRoot(), }:{ type?:string|null; id?:string|null; + dropPosition?:string|null; root?:SortableListsRoot|null; } = {}) { fixture.innerHTML = `
                                                    + ${id != null ? `data-sortable-lists--list-id-value="${id}"` : ''} + ${dropPosition != null ? `data-sortable-lists--list-drop-position-value="${dropPosition}"` : ''}>
                                                  `; const list = fixture.querySelector('[data-controller~="sortable-lists--list"]')!; await ctx.nextFrame(); @@ -129,6 +132,27 @@ describe('Sortable lists list controller', () => { .toEqual(expect.objectContaining({ type: 'sprint', listId: '7' })); }); + it('defaults its list-only drop position to end', async () => { + const { list } = await connectedListFor({ type: 'sprint', id: '7' }); + + expect(dropTargetOptionsFor(list)?.getData?.({ element: list, input: {} as never, source: source(null) })) + .toEqual(expect.objectContaining({ dropPosition: 'end' })); + }); + + it('exposes the configured list-only drop position', async () => { + const { list } = await connectedListFor({ type: 'sprint', id: '7', dropPosition: 'start' }); + + expect(dropTargetOptionsFor(list)?.getData?.({ element: list, input: {} as never, source: source(null) })) + .toEqual(expect.objectContaining({ dropPosition: 'start' })); + }); + + it('falls back to end for an unknown drop position value', async () => { + const { list } = await connectedListFor({ type: 'sprint', id: '7', dropPosition: 'sideways' }); + + expect(dropTargetOptionsFor(list)?.getData?.({ element: list, input: {} as never, source: source(null) })) + .toEqual(expect.objectContaining({ dropPosition: 'end' })); + }); + it('accepts a same-root item of the accepted type', async () => { const root = document.createElement('div'); const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package' }) }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts index f0c920f695ea..036b36bad525 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts @@ -33,21 +33,26 @@ import { sortableListData, type RootAwareChild, type SortableListData, + type SortableListDropPosition, type SortableListsRoot, } from './drag-and-drop'; type CleanupFn = () => void; +const dropPositions = new Set(['start', 'end']); + export default class ListController extends Controller implements RootAwareChild { static values = { type: String, id: String, + dropPosition: { type: String, default: 'end' }, }; declare readonly typeValue:string; declare readonly hasTypeValue:boolean; declare readonly idValue:string; declare readonly hasIdValue:boolean; + declare readonly dropPositionValue:string; private root?:SortableListsRoot; private cleanupFn?:CleanupFn; @@ -93,10 +98,15 @@ export default class ListController extends Controller implements R } } + private get dropPosition():SortableListDropPosition { + return dropPositions.has(this.dropPositionValue) ? this.dropPositionValue as SortableListDropPosition : 'end'; + } + private get listData():SortableListData { return sortableListData({ type: this.typeValue, listId: this.hasIdValue ? this.idValue : null, + dropPosition: this.dropPosition, }); } diff --git a/modules/backlogs/app/components/backlogs/bucket_component.html.erb b/modules/backlogs/app/components/backlogs/bucket_component.html.erb index f479082b188a..a0d95ef651b8 100644 --- a/modules/backlogs/app/components/backlogs/bucket_component.html.erb +++ b/modules/backlogs/app/components/backlogs/bucket_component.html.erb @@ -36,7 +36,8 @@ See COPYRIGHT and LICENSE files for more details. container: backlog_bucket, drag_and_drop: { list_type: "backlog_bucket", - list_id: backlog_bucket.id + list_id: backlog_bucket.id, + drop_position: "start" }, params: backlog_filter_params, current_user:, diff --git a/modules/backlogs/app/components/backlogs/inbox_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_component.html.erb index 30cb7315744f..a15a01b8cb99 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb @@ -40,7 +40,8 @@ See COPYRIGHT and LICENSE files for more details. test_selector: "backlog-inbox", data: { controller: "sortable-lists--list", - sortable_lists__list_type_value: "inbox" + sortable_lists__list_type_value: "inbox", + sortable_lists__list_drop_position_value: "start" } ) ) do |list| diff --git a/modules/backlogs/app/components/backlogs/sprint_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_component.html.erb index 3456569081d5..829221a0cbb9 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_component.html.erb @@ -36,7 +36,8 @@ See COPYRIGHT and LICENSE files for more details. container: sprint, drag_and_drop: { list_type: "sprint", - list_id: sprint.id + list_id: sprint.id, + drop_position: "start" }, params: backlog_filter_params, current_user:, diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb index b2cd49e80c14..1df92178b424 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb @@ -120,6 +120,7 @@ def drag_and_drop_data sortable_lists__list_type_value: drag_and_drop.fetch(:list_type) } data[:sortable_lists__list_id_value] = drag_and_drop[:list_id] if drag_and_drop[:list_id].present? + data[:sortable_lists__list_drop_position_value] = drag_and_drop[:drop_position] if drag_and_drop[:drop_position].present? data end diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index 022adc848b45..9781b71c2b9d 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -123,6 +123,7 @@ def render_component expect(box["data-controller"]).to include("sortable-lists--list") expect(box["data-sortable-lists--list-type-value"]).to eq("backlog_bucket") expect(box["data-sortable-lists--list-id-value"]).to eq(backlog_bucket.id.to_s) + expect(box["data-sortable-lists--list-drop-position-value"]).to eq("start") end end diff --git a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb index 784ec046450b..c1d88d96edd7 100644 --- a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb @@ -69,6 +69,7 @@ def render_component expect(box["data-controller"]).to include("sortable-lists--list") expect(box["data-sortable-lists--list-type-value"]).to eq("inbox") expect(box["data-sortable-lists--list-id-value"]).to be_nil + expect(box["data-sortable-lists--list-drop-position-value"]).to eq("start") end end diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index 99ebb41a1e56..30f067e753ee 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -106,6 +106,7 @@ def menu_items expect(box["data-controller"]).to include("sortable-lists--list") expect(box["data-sortable-lists--list-type-value"]).to eq("sprint") expect(box["data-sortable-lists--list-id-value"]).to eq(sprint.id.to_s) + expect(box["data-sortable-lists--list-drop-position-value"]).to eq("start") end end From 86524cd78b1cca4609bafe77e8429e8f890ca8dc Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 24 Jun 2026 23:00:19 +0200 Subject: [PATCH 058/105] Add drop-zone outline for sortable list containers When a dragged item is not over a row, nothing signalled which list it would drop into. Outlining the hovered list makes the drop target obvious during a drag. --- .../common/border_box_list_component.sass | 7 +++++++ .../dynamic/sortable-lists/list.controller.ts | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index 3682d3ed2798..e4c321ce917f 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -191,6 +191,13 @@ content: '' bottom: calc(var(--op-backlogs-drop-gap-height) / -2) +// Container drop zone: the whole list outlines while a sortable item hovers it, +// signalling the item will land inside this list. +[data-controller~="sortable-lists--list"][data-drop-container] + outline: var(--borderWidth-thick) solid var(--fgColor-accent) + outline-offset: calc(var(--borderWidth-thick) * -1) + border-radius: var(--borderRadius-medium) + // Reapplies the padding removed from the containing .Box-row, matching the // .Box-row padding of each Box density variant upstream. .Box-card diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts index 036b36bad525..fa355ae2be35 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts @@ -68,6 +68,18 @@ export default class ListController extends Controller implements R canDrop: ({ source }) => this.canDrop(source.data), getData: () => this.listData, getIsSticky: () => false, + onDragEnter: () => { + this.renderDropIndicator(); + }, + onDrag: () => { + this.renderDropIndicator(); + }, + onDragLeave: () => { + this.clearDropIndicator(); + }, + onDrop: () => { + this.clearDropIndicator(); + }, }); } @@ -118,4 +130,12 @@ export default class ListController extends Controller implements R return canAccept(root, data); } + + private renderDropIndicator():void { + this.element.dataset.dropContainer = 'active'; + } + + private clearDropIndicator():void { + delete this.element.dataset.dropContainer; + } } From c28621c9ae837debc5be253d268285ae760bac51 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 24 Jun 2026 23:24:32 +0200 Subject: [PATCH 059/105] Outline list only when no row is targeted The list is the parent drop target of its rows, so its drag callbacks keep firing while the pointer is over a row. The container outline and the row gap indicator then showed at once, the gap cutting through the outline. The list now outlines only for a list-only drop, letting the row indicator own the over-a-row case. --- .../sortable-lists/list.controller.spec.ts | 44 +++++++++++++++++++ .../dynamic/sortable-lists/list.controller.ts | 21 +++++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts index f63535c09af0..f88cc4af5ce6 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts @@ -113,6 +113,10 @@ describe('Sortable lists list controller', () => { } as never; } + function locationOver(...dropTargets:{ data:Record }[]) { + return { current: { dropTargets } } as never; + } + it('registers itself as a drop target', async () => { const { list } = await connectedListFor(); @@ -221,6 +225,46 @@ describe('Sortable lists list controller', () => { expect(list.hasAttribute('aria-busy')).toBe(false); }); + it('outlines the container for a list-only drop', async () => { + const { list } = await connectedListFor(); + const options = dropTargetOptionsFor(list); + + options?.onDragEnter?.({ location: locationOver() } as never); + + expect(list.dataset.dropContainer).toEqual('active'); + }); + + it('does not outline the container while the pointer is over an item row', async () => { + const { list } = await connectedListFor(); + const options = dropTargetOptionsFor(list); + + options?.onDrag?.({ location: locationOver({ data: sortableItemData({ itemId: '1', type: 'work_package', rootElement: null }) }) } as never); + + expect(list.dataset.dropContainer).toBeUndefined(); + }); + + it('clears the container outline when the pointer moves from the list onto an item row', async () => { + const { list } = await connectedListFor(); + const options = dropTargetOptionsFor(list); + + options?.onDragEnter?.({ location: locationOver() } as never); + expect(list.dataset.dropContainer).toEqual('active'); + + options?.onDrag?.({ location: locationOver({ data: sortableItemData({ itemId: '1', type: 'work_package', rootElement: null }) }) } as never); + expect(list.dataset.dropContainer).toBeUndefined(); + }); + + it('clears the container outline on drag leave', async () => { + const { list } = await connectedListFor(); + const options = dropTargetOptionsFor(list); + + options?.onDragEnter?.({ location: locationOver() } as never); + expect(list.dataset.dropContainer).toEqual('active'); + + options?.onDragLeave?.({} as never); + expect(list.dataset.dropContainer).toBeUndefined(); + }); + it('rejects drops while the root is moving', async () => { const root = document.createElement('div'); const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package', moving: true }) }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts index fa355ae2be35..df790b9c7711 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts @@ -27,9 +27,11 @@ //++ import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { type DragLocationHistory } from '@atlaskit/pragmatic-drag-and-drop/types'; import { Controller } from '@hotwired/stimulus'; import { canAccept, + isSortableItemData, sortableListData, type RootAwareChild, type SortableListData, @@ -68,11 +70,11 @@ export default class ListController extends Controller implements R canDrop: ({ source }) => this.canDrop(source.data), getData: () => this.listData, getIsSticky: () => false, - onDragEnter: () => { - this.renderDropIndicator(); + onDragEnter: ({ location }) => { + this.syncDropIndicator(location); }, - onDrag: () => { - this.renderDropIndicator(); + onDrag: ({ location }) => { + this.syncDropIndicator(location); }, onDragLeave: () => { this.clearDropIndicator(); @@ -131,6 +133,17 @@ export default class ListController extends Controller implements R return canAccept(root, data); } + // The list is the item targets' parent drop target, so its onDrag keeps firing + // while the pointer is over a row. Outline the container only for a list-only + // drop (no item target in play), so the row gap indicator owns that case. + private syncDropIndicator(location:DragLocationHistory):void { + if (location.current.dropTargets.some(({ data }) => isSortableItemData(data))) { + this.clearDropIndicator(); + } else { + this.renderDropIndicator(); + } + } + private renderDropIndicator():void { this.element.dataset.dropContainer = 'active'; } From 1bf420ae59effd019e0d9a615845d0290585fbd4 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 24 Jun 2026 23:28:14 +0200 Subject: [PATCH 060/105] Set user-select: none on cards Attempt to mitigate interference with draggable behaviour, especially on mobile devices. --- .../open_project/common/border_box_list_component.sass | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index e4c321ce917f..671b07016b41 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -120,6 +120,7 @@ .Box-card--draggable cursor: grab + user-select: none &:active cursor: grabbing From 3479ec6c59a89058be87c1d51fb6a23852880720 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 26 Jun 2026 16:18:34 +0200 Subject: [PATCH 061/105] Bump Pragmatic Drag and Drop dependencies --- frontend/package-lock.json | 22 ++++++++++++++-------- frontend/package.json | 6 +++--- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 202c89a433dd..3023be592762 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,9 +25,9 @@ "@appsignal/javascript": "^1.6.1", "@appsignal/plugin-breadcrumbs-console": "^1.1.37", "@appsignal/plugin-breadcrumbs-network": "^1.1.24", - "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", - "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5", - "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0", + "@atlaskit/pragmatic-drag-and-drop": "^2.0.1", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.0", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0", "@blocknote/core": "^0.51.4", "@blocknote/mantine": "^0.51.4", "@blocknote/react": "^0.51.3", @@ -1875,7 +1875,9 @@ "license": "MIT" }, "node_modules/@atlaskit/pragmatic-drag-and-drop": { - "version": "1.8.1", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-2.0.1.tgz", + "integrity": "sha512-5iSbCveKuWYh0HijDxfSsYKD2VE0UPObI9jO1pqq+RBk9LzaoOYeKyW0Fobv/6db8HAPcyT3vhRy8bmk5TMcMw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.0.0", @@ -1884,18 +1886,22 @@ } }, "node_modules/@atlaskit/pragmatic-drag-and-drop-auto-scroll": { - "version": "2.1.5", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-auto-scroll/-/pragmatic-drag-and-drop-auto-scroll-3.0.0.tgz", + "integrity": "sha512-8TlkMi417zIPrTnCpbNV0Ce63JTKBn3dGkqiadkGQROvzDAox7Y9TYRHpvbQiEdtPoHWnmyRBi+RsGDxXsQxgQ==", "license": "Apache-2.0", "dependencies": { - "@atlaskit/pragmatic-drag-and-drop": "^1.7.0", + "@atlaskit/pragmatic-drag-and-drop": "^2.0.0", "@babel/runtime": "^7.0.0" } }, "node_modules/@atlaskit/pragmatic-drag-and-drop-hitbox": { - "version": "1.2.0", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-2.0.0.tgz", + "integrity": "sha512-vkXCB/23pT8YgYU8biGmR9uu0fr9oWoaq2GMSKyiNPk3reA12XVrCdgB2SLBoB24Ic1FOm9PdaQJ9ZSUQdCVpw==", "license": "Apache-2.0", "dependencies": { - "@atlaskit/pragmatic-drag-and-drop": "^1.8.0", + "@atlaskit/pragmatic-drag-and-drop": "^2.0.0", "@babel/runtime": "^7.0.0" } }, diff --git a/frontend/package.json b/frontend/package.json index 4490a517da67..20d915e26097 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -70,9 +70,9 @@ "@appsignal/javascript": "^1.6.1", "@appsignal/plugin-breadcrumbs-console": "^1.1.37", "@appsignal/plugin-breadcrumbs-network": "^1.1.24", - "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", - "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5", - "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.2.0", + "@atlaskit/pragmatic-drag-and-drop": "^2.0.1", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.0.0", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0", "@blocknote/core": "^0.51.4", "@blocknote/mantine": "^0.51.4", "@blocknote/react": "^0.51.3", From 7ce4b9ec2f0c9e571b3553a0afcb100b505ec796 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 26 Jun 2026 21:15:47 +0200 Subject: [PATCH 062/105] Run npm audit fix --- frontend/package-lock.json | 332 ++++++++++++++++++++++--------------- 1 file changed, 195 insertions(+), 137 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 544a48a0d696..f423fcb77fa9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -409,11 +409,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.2200.1", + "version": "0.2200.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2200.4.tgz", + "integrity": "sha512-X/iEQiZ0pRmpjUt11jCM+mtOLRl4XxI9hnM0IC9aAcsm5AzRBb9WY6QIEqOSficjxC/+MI7MGwrerrcP6QN8UA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "22.0.1", + "@angular-devkit/core": "22.0.4", "rxjs": "7.8.2" }, "bin": { @@ -426,7 +428,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "22.0.1", + "version": "22.0.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.0.4.tgz", + "integrity": "sha512-zA2UJSMAU3su5uJTOn5ul/gCLRcw6/uIQ6EC5v/Ju/ePjgDIw9R3y3MAvWQ4Ibi/fXiq0FVxpF8hE7RUclYmJA==", "dev": true, "license": "MIT", "dependencies": { @@ -672,12 +676,14 @@ } }, "node_modules/@angular/build": { - "version": "22.0.1", + "version": "22.0.4", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-22.0.4.tgz", + "integrity": "sha512-hst/KhP5mMPajY32l7qmyW/5fuqrMHCjHEeNhMMNqP82+j8jPBFfMEL26AH9w2kIIdKGpC3WKN5JgLotyFD7tQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2200.1", + "@angular-devkit/architect": "0.2200.4", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -685,7 +691,7 @@ "@vitejs/plugin-basic-ssl": "2.3.0", "beasties": "0.4.2", "browserslist": "^4.26.0", - "esbuild": "0.28.0", + "esbuild": "0.28.1", "https-proxy-agent": "9.0.0", "jsonc-parser": "3.3.1", "listr2": "10.2.1", @@ -693,13 +699,13 @@ "mrmime": "2.0.1", "parse5-html-rewriting-stream": "8.0.1", "picomatch": "4.0.4", - "piscina": "5.1.4", + "piscina": "5.2.0", "rollup": "4.60.2", "sass": "1.99.0", "semver": "7.7.4", "source-map-support": "0.5.21", "tinyglobby": "0.2.16", - "vite": "7.3.2", + "vite": "7.3.5", "watchpack": "2.5.1" }, "engines": { @@ -718,7 +724,7 @@ "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", - "@angular/ssr": "^22.0.1", + "@angular/ssr": "^22.0.4", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", @@ -772,9 +778,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -789,9 +795,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -806,9 +812,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -823,9 +829,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -840,7 +846,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -855,9 +863,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -872,9 +880,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -889,9 +897,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -906,9 +914,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -923,9 +931,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -940,9 +948,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -957,9 +965,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -974,9 +982,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -991,9 +999,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -1008,9 +1016,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -1025,9 +1033,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -1042,9 +1050,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -1059,9 +1067,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -1076,9 +1084,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -1093,9 +1101,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -1110,9 +1118,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1127,9 +1135,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -1144,9 +1152,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1161,9 +1169,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1178,9 +1186,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1195,9 +1203,9 @@ } }, "node_modules/@angular/build/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1220,7 +1228,9 @@ } }, "node_modules/@angular/build/node_modules/esbuild": { - "version": "0.28.0", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1231,32 +1241,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/@angular/build/node_modules/https-proxy-agent": { @@ -1535,10 +1545,12 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "22.0.2", + "version": "22.0.4", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.0.4.tgz", + "integrity": "sha512-UmZRpw3pStRmH3ZOLg3BfyGaTxu/aqL00U1eoE3yfsUea25zJLU+gC4vx3NYtHzYKQu84wX3gTZXhLuuYYTa8w==", "license": "MIT", "dependencies": { - "@babel/core": "7.29.0", + "@babel/core": "7.29.7", "@jridgewell/sourcemap-codec": "^1.4.14", "chokidar": "^5.0.0", "convert-source-map": "^1.5.1", @@ -1555,7 +1567,7 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/compiler": "22.0.2", + "@angular/compiler": "22.0.4", "typescript": ">=6.0 <6.1" }, "peerDependenciesMeta": { @@ -1564,6 +1576,51 @@ } } }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@angular/compiler-cli/node_modules/ansi-regex": { "version": "6.1.0", "license": "MIT", @@ -1941,6 +1998,7 @@ }, "node_modules/@babel/core": { "version": "7.29.0", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -1969,21 +2027,25 @@ }, "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", + "dev": true, "license": "MIT" }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/generator": { - "version": "7.29.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -2090,11 +2152,13 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2158,20 +2222,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.29.7", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { "version": "7.29.7", "license": "MIT", @@ -4528,6 +4578,8 @@ }, "node_modules/@napi-rs/nice": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", "dev": true, "license": "MIT", "optional": true, @@ -4594,6 +4646,8 @@ }, "node_modules/@napi-rs/nice-darwin-arm64": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", "cpu": [ "arm64" ], @@ -12962,7 +13016,9 @@ } }, "node_modules/piscina": { - "version": "5.1.4", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.2.0.tgz", + "integrity": "sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==", "dev": true, "license": "MIT", "engines": { @@ -15260,7 +15316,9 @@ } }, "node_modules/vite": { - "version": "7.3.2", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", "dependencies": { From c3b9162b5371c1794ddc1560aebd6d352e007bcf Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 27 Jun 2026 00:04:18 +0200 Subject: [PATCH 063/105] Wait for backlogs frame reload in DnD specs After PR #23885 a backlogs move responds with a `turbo_frame_reload` stream action, so `backlogs_container` reloads itself in a second request. `wait_for_backlogs_turbo_stream` returned after the stream render and raced that reload, hitting `StaleElementReferenceError` on CI. The drag and move-submenu helpers now wait for the frame's `turbo:frame-load`, and the Selenium-only DnD helpers rescue the Selenium stale error. --- .../backlogs/spec/support/pages/backlog.rb | 92 +++++++++++-------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index a9e01bd11540..cf3014ffb314 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -338,9 +338,12 @@ def within_work_package_move_submenu(work_package, &) end end - def click_in_work_package_move_submenu(work_package, item_name, wait: true) + # The move submenu items (Move up/down/to top/to bottom) all perform a + # successful move, which reloads the `backlogs_container` frame. Wait for that + # reload so a subsequent drag does not grab a soon-to-be-detached element. + def click_in_work_package_move_submenu(work_package, item_name, wait: true, frame_reload: true) within_work_package_move_submenu(work_package) do |submenu| - wait_for_backlogs_turbo_stream(wait:) do + wait_for_backlogs_turbo_stream(wait:, frame_reload:) do submenu.find(:menuitem, text: item_name, visible: :all).click end end @@ -451,7 +454,7 @@ def pick_up_and_release_work_package(work_package) install_backlogs_move_request_probe pick_up_and_release_backlogs_item(moved_element) return - rescue Capybara::Cuprite::ObsoleteNode + rescue Capybara::Cuprite::ObsoleteNode, Selenium::WebDriver::Error::StaleElementReferenceError next ensure stop_backlogs_move_request_probe @@ -553,10 +556,10 @@ def drag_work_package(moved, before: nil, into: nil) find(sprint_selector(into)) end - wait_for_backlogs_turbo_stream do + wait_for_backlogs_turbo_stream(frame_reload: true) do drag_backlogs_item(source: moved_element, target: target_element, edge: before ? :top : nil) end - rescue Capybara::Cuprite::ObsoleteNode + rescue Capybara::Cuprite::ObsoleteNode, Selenium::WebDriver::Error::StaleElementReferenceError retry end @@ -566,12 +569,12 @@ def drag_work_package_to_backlog_inbox(work_package) target_item = inbox.all("[data-sortable-lists--item-id-value]", minimum: 0).last target_element = target_item || inbox.find("[data-empty-list-item]") - wait_for_backlogs_turbo_stream do + wait_for_backlogs_turbo_stream(frame_reload: true) do drag_backlogs_item(source: moved_element, target: target_element, edge: target_item ? :bottom : nil) end wait_for { work_package.reload.backlog_bucket_id }.to be_nil wait_for { work_package.reload.sprint_id }.to be_nil - rescue Capybara::Cuprite::ObsoleteNode + rescue Capybara::Cuprite::ObsoleteNode, Selenium::WebDriver::Error::StaleElementReferenceError retry end @@ -579,22 +582,22 @@ def drag_work_package_to_backlog_bucket(work_package, bucket) moved_element = find(draggable_work_package_selector(work_package)) target_element = find(list_body_selector(bucket_selector(bucket))) - wait_for_backlogs_turbo_stream do + wait_for_backlogs_turbo_stream(frame_reload: true) do drag_backlogs_item(source: moved_element, target: target_element) end wait_for { work_package.reload.backlog_bucket_id }.to eq(bucket.id) - rescue Capybara::Cuprite::ObsoleteNode + rescue Capybara::Cuprite::ObsoleteNode, Selenium::WebDriver::Error::StaleElementReferenceError retry end def drag_work_package_to_sprint(work_package, sprint) moved_element = find(draggable_work_package_selector(work_package)) target_element = find(list_body_selector(sprint_selector(sprint))) - wait_for_backlogs_turbo_stream do + wait_for_backlogs_turbo_stream(frame_reload: true) do drag_backlogs_item(source: moved_element, target: target_element) end wait_for { work_package.reload.sprint_id }.to eq(sprint.id) - rescue Capybara::Cuprite::ObsoleteNode + rescue Capybara::Cuprite::ObsoleteNode, Selenium::WebDriver::Error::StaleElementReferenceError retry end @@ -724,11 +727,7 @@ def draggable_work_package_selector(work_package) end def drag_backlogs_item(source:, target:, edge: nil) - if selenium_driver? - selenium_drag_backlogs_item(source:, target:, edge:) - else - source.native.drag_to(target.native, delay: 0.1) - end + selenium_drag_backlogs_item(source:, target:, edge:) end def pick_up_and_release_backlogs_item(source) @@ -736,21 +735,17 @@ def pick_up_and_release_backlogs_item(source) scroll_to_element(source) - if selenium_driver? - page - .driver - .browser - .action - .move_to(source.native) - .click_and_hold - .pause(duration: 0.1) - .move_by(0, 8) - .pause(duration: 0.1) - .release - .perform - else - source.native.drag_to(source.native, delay: 0.1) - end + page + .driver + .browser + .action + .move_to(source.native) + .click_and_hold + .pause(duration: 0.1) + .move_by(0, 8) + .pause(duration: 0.1) + .release + .perform # Assert Pragmatic DnD tore down its own honey-pot overlay before we force # a cleanup, so a regression that leaves the overlay stuck is caught here @@ -815,7 +810,18 @@ def wait_for_backlogs_network_idle wait_for_network_idle if using_cuprite? end - def wait_for_backlogs_turbo_stream(wait: 10, &) + # Waits for a backlogs move to settle. + # + # A move responds with a turbo stream whose `turbo_frame_reload` action tells + # the `backlogs_container` frame to reload itself. That is two round-trips: the + # stream render (request 1, `op:turbo-stream-rendered`) and the frame reload it + # triggers (request 2, `turbo:frame-load`). The DOM is only swapped when request + # 2 completes, so callers that go on to interact with the refreshed frame must + # pass `frame_reload: true` to wait for the frame load rather than racing it. + # + # Moves that fail (e.g. dropping onto a completed sprint) only render an error + # flash and never reload the frame, so the default settles on the stream render. + def wait_for_backlogs_turbo_stream(wait: 10, frame_reload: false, &) return yield unless wait return wait_for_turbo_stream(wait:, &) if using_cuprite? @@ -827,6 +833,7 @@ def wait_for_backlogs_turbo_stream(wait: 10, &) const controller = new AbortController(); const state = { rendered: false, + frameReloaded: false, timeoutMs: arguments[0], events: [] }; @@ -839,25 +846,38 @@ def wait_for_backlogs_turbo_stream(wait: 10, &) }); }, { signal: controller.signal }); + document.addEventListener('turbo:frame-load', (event) => { + if (event.target instanceof Element && event.target.id === 'backlogs_container') { + state.frameReloaded = true; + state.events.push({ + type: event.type, + target: event.target.id, + time: Math.round(performance.now()) + }); + } + }, { signal: controller.signal }); + window.__opBacklogsTurboStreamAbort = controller; window.__opBacklogsTurboStreamState = state; JS yield - wait_for_backlogs_turbo_stream_event(timeout:) + wait_for_backlogs_turbo_stream_event(timeout:, frame_reload:) ensure stop_backlogs_turbo_stream_probe unless using_cuprite? end - def wait_for_backlogs_turbo_stream_event(timeout:) + def wait_for_backlogs_turbo_stream_event(timeout:, frame_reload: false) + flag = frame_reload ? "frameReloaded" : "rendered" deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout loop do - return if page.evaluate_script("window.__opBacklogsTurboStreamState?.rendered === true") + return if page.evaluate_script("window.__opBacklogsTurboStreamState?.#{flag} === true") if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline - raise "wait_for_backlogs_turbo_stream: no turbo stream rendered\n#{backlogs_dnd_diagnostics}" + missing = frame_reload ? "backlogs_container frame did not reload" : "no turbo stream rendered" + raise "wait_for_backlogs_turbo_stream: #{missing}\n#{backlogs_dnd_diagnostics}" end sleep 0.05 From 2057ea1012f7e1db6fb5526cce2f36097181269f Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 27 Jun 2026 14:19:43 +0200 Subject: [PATCH 064/105] Delegate backlogs turbo wait to shared helpers `wait_for_backlogs_turbo_stream` hand-rolled a Selenium turbo-stream and frame-load probe that duplicated the now driver-agnostic shared helpers. The `frame_reload: true` path now delegates to the shared `wait_for_turbo_frame(frame: "backlogs_container")` and the failed-move path to `wait_for_turbo_stream`, so the inline JS probe and its diagnostics helper are dropped. (cherry picked from commit bdcec37bdb619165000cea1db2a1c3227fb5da27) --- .../backlogs/spec/support/pages/backlog.rb | 112 ++---------------- 1 file changed, 10 insertions(+), 102 deletions(-) diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index cf3014ffb314..abe95f0391f7 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -812,82 +812,23 @@ def wait_for_backlogs_network_idle # Waits for a backlogs move to settle. # - # A move responds with a turbo stream whose `turbo_frame_reload` action tells - # the `backlogs_container` frame to reload itself. That is two round-trips: the - # stream render (request 1, `op:turbo-stream-rendered`) and the frame reload it - # triggers (request 2, `turbo:frame-load`). The DOM is only swapped when request - # 2 completes, so callers that go on to interact with the refreshed frame must - # pass `frame_reload: true` to wait for the frame load rather than racing it. + # A successful move responds with a turbo stream whose `turbo_frame_reload` + # action reloads the `backlogs_container` frame — two round-trips: the stream + # render and the frame reload. Callers that go on to touch the refreshed frame + # pass `frame_reload: true` to await the frame load rather than racing it. # - # Moves that fail (e.g. dropping onto a completed sprint) only render an error - # flash and never reload the frame, so the default settles on the stream render. + # A failed move (e.g. dropping onto a completed sprint) only renders an error + # flash and never reloads the frame, so the default settles on the stream render. def wait_for_backlogs_turbo_stream(wait: 10, frame_reload: false, &) return yield unless wait - return wait_for_turbo_stream(wait:, &) if using_cuprite? - timeout = wait == true ? 10 : wait - timeout_ms = timeout * 1000 - page.execute_script(<<~JS, timeout_ms) - window.__opBacklogsTurboStreamAbort?.abort(); - - const controller = new AbortController(); - const state = { - rendered: false, - frameReloaded: false, - timeoutMs: arguments[0], - events: [] - }; - - document.addEventListener('op:turbo-stream-rendered', (event) => { - state.rendered = true; - state.events.push({ - type: event.type, - time: Math.round(performance.now()) - }); - }, { signal: controller.signal }); - - document.addEventListener('turbo:frame-load', (event) => { - if (event.target instanceof Element && event.target.id === 'backlogs_container') { - state.frameReloaded = true; - state.events.push({ - type: event.type, - target: event.target.id, - time: Math.round(performance.now()) - }); - } - }, { signal: controller.signal }); - - window.__opBacklogsTurboStreamAbort = controller; - window.__opBacklogsTurboStreamState = state; - JS - - yield - - wait_for_backlogs_turbo_stream_event(timeout:, frame_reload:) - ensure - stop_backlogs_turbo_stream_probe unless using_cuprite? - end - - def wait_for_backlogs_turbo_stream_event(timeout:, frame_reload: false) - flag = frame_reload ? "frameReloaded" : "rendered" - deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout - - loop do - return if page.evaluate_script("window.__opBacklogsTurboStreamState?.#{flag} === true") - - if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline - missing = frame_reload ? "backlogs_container frame did not reload" : "no turbo stream rendered" - raise "wait_for_backlogs_turbo_stream: #{missing}\n#{backlogs_dnd_diagnostics}" - end - - sleep 0.05 + if frame_reload + wait_for_turbo_frame(frame: "backlogs_container", wait:, &) + else + wait_for_turbo_stream(wait:, &) end end - def stop_backlogs_turbo_stream_probe - page.execute_script("window.__opBacklogsTurboStreamAbort?.abort();") - end - def install_backlogs_dnd_probe(source:, target:, edge:) page.execute_script(<<~JS, source, target, edge&.to_s) window.__opBacklogsDndProbeAbort?.abort(); @@ -1124,39 +1065,6 @@ def stop_backlogs_move_request_probe JS end - def backlogs_dnd_diagnostics - diagnostics = page.evaluate_script(<<~JS) - (() => { - const dnd = window.__opBacklogsDndProbeState ?? null; - const turbo = window.__opBacklogsTurboStreamState ?? null; - - if (dnd) { - dnd.snapshots.push({ - label: 'on-timeout', - draggingCount: document.querySelectorAll('[data-dragging]').length, - honeyPotCount: document.querySelectorAll('[data-pdnd-honey-pot]').length, - dropTargets: document.querySelectorAll('[data-drop-target-for-element]').length, - dropPositions: Array - .from(document.querySelectorAll('[data-drop-position]')) - .map((element) => ({ - itemId: element - .closest('[data-sortable-lists--item-id-value]') - ?.getAttribute('data-sortable-lists--item-id-value') ?? null, - position: element.getAttribute('data-drop-position') - })), - source: dnd.source, - target: dnd.target, - time: Math.round(performance.now()) - }); - } - - return { dnd, turbo }; - })() - JS - - JSON.pretty_generate(diagnostics) - end - def clear_pragmatic_dnd_honey_pot page.execute_script(<<~JS) document From 6b5046b350252f467e887f06b19330ad5cdb9e19 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 29 Jun 2026 11:00:26 +0200 Subject: [PATCH 065/105] Align backlogs turbo wait default with Capybara Mainline standardised the turbo wait helpers on Capybara.default_max_wait_time; the backlogs wrapper kept a literal 10s. Derive its default from the same source for consistency. --- modules/backlogs/spec/support/pages/backlog.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index abe95f0391f7..f630d4d004ce 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -819,7 +819,7 @@ def wait_for_backlogs_network_idle # # A failed move (e.g. dropping onto a completed sprint) only renders an error # flash and never reloads the frame, so the default settles on the stream render. - def wait_for_backlogs_turbo_stream(wait: 10, frame_reload: false, &) + def wait_for_backlogs_turbo_stream(wait: Capybara.default_max_wait_time, frame_reload: false, &) return yield unless wait if frame_reload From d17290ec2b91dec20f846e395e1030eee9a66efe Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 30 Jun 2026 19:26:07 +0100 Subject: [PATCH 066/105] Rename Backlogs story controller to work-package Renames the `backlogs--story` Stimulus controller and its `story:` data attributes to `work_package` terminology, aligning the drag-and- drop subsystem with the branch's own `modules/backlogs/CONTEXT.md`, which lists "story" as a word to avoid for a Work package. --- ...pec.ts => work-package.controller.spec.ts} | 86 +++++++++---------- ...ntroller.ts => work-package.controller.ts} | 8 +- .../sortable-lists/item.controller.spec.ts | 10 +-- .../work_package_card_list_item_component.rb | 10 +-- .../work_package_card_menu_component.html.erb | 2 +- .../backlogs/bucket_component_spec.rb | 8 +- .../backlogs/sprint_component_spec.rb | 4 +- .../work_package_card_component_spec.rb | 4 +- .../work_package_card_list_component_spec.rb | 4 +- ...k_package_card_list_item_component_spec.rb | 18 ++-- .../work_package_card_menu_component_spec.rb | 2 +- 11 files changed, 78 insertions(+), 78 deletions(-) rename frontend/src/stimulus/controllers/dynamic/backlogs/{story.controller.spec.ts => work-package.controller.spec.ts} (67%) rename frontend/src/stimulus/controllers/dynamic/backlogs/{story.controller.ts => work-package.controller.ts} (95%) diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.spec.ts similarity index 67% rename from frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts rename to frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.spec.ts index 087bed4e13aa..00fbbf1f22a5 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.spec.ts @@ -28,29 +28,29 @@ import { Application } from '@hotwired/stimulus'; -import type StoryControllerType from './story.controller'; +import type WorkPackageControllerType from './work-package.controller'; -interface StoryNavigation { +interface WorkPackageNavigation { openSplitPane(this:void):void; openFullPane(this:void):void; } -describe('Backlogs story controller', () => { +describe('Backlogs work package controller', () => { const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); let application:Application; let fixture:HTMLElement; - let StoryController:typeof StoryControllerType; - let navigation:StoryNavigation; + let WorkPackageController:typeof WorkPackageControllerType; + let navigation:WorkPackageNavigation; beforeAll(async () => { - ({ default: StoryController } = await import('./story.controller')); + ({ default: WorkPackageController } = await import('./work-package.controller')); }); beforeEach(() => { // Stub the navigation so activating a card neither hits Turbo nor leaves // the test page; the spies double as activation assertions. - navigation = StoryController.prototype as unknown as StoryNavigation; + navigation = WorkPackageController.prototype as unknown as WorkPackageNavigation; vi.spyOn(navigation, 'openSplitPane').mockReturnValue(undefined); vi.spyOn(navigation, 'openFullPane').mockReturnValue(undefined); @@ -58,7 +58,7 @@ describe('Backlogs story controller', () => { document.body.appendChild(fixture); application = Application.start(); - application.register('backlogs--story', StoryController); + application.register('backlogs--work-package', WorkPackageController); }); afterEach(async () => { @@ -68,21 +68,21 @@ describe('Backlogs story controller', () => { vi.restoreAllMocks(); }); - function renderStory() { + function renderWorkPackage() { fixture.innerHTML = `
                                                  - Story + Work package
                                                  `; - return fixture.querySelector('[data-controller="backlogs--story"]')!; + return fixture.querySelector('[data-controller="backlogs--work-package"]')!; } function keydown(target:HTMLElement, key:string, init:KeyboardEventInit = {}) { @@ -94,10 +94,10 @@ describe('Backlogs story controller', () => { } it('prevents Space from scrolling the page without activating the card', async () => { - const story = renderStory(); + const workPackage = renderWorkPackage(); await nextFrame(); - const event = keydown(story, ' '); + const event = keydown(workPackage, ' '); expect(event.defaultPrevented).toBe(true); expect(navigation.openSplitPane).not.toHaveBeenCalled(); @@ -105,10 +105,10 @@ describe('Backlogs story controller', () => { }); it('opens the split pane when Enter is pressed', async () => { - const story = renderStory(); + const workPackage = renderWorkPackage(); await nextFrame(); - const event = keydown(story, 'Enter'); + const event = keydown(workPackage, 'Enter'); expect(event.defaultPrevented).toBe(true); expect(navigation.openSplitPane).toHaveBeenCalledTimes(1); @@ -116,10 +116,10 @@ describe('Backlogs story controller', () => { }); it('opens the full pane when Shift+Enter is pressed', async () => { - const story = renderStory(); + const workPackage = renderWorkPackage(); await nextFrame(); - const event = keydown(story, 'Enter', { shiftKey: true }); + const event = keydown(workPackage, 'Enter', { shiftKey: true }); expect(event.defaultPrevented).toBe(true); expect(navigation.openFullPane).toHaveBeenCalledTimes(1); @@ -127,9 +127,9 @@ describe('Backlogs story controller', () => { }); it('ignores Space inside a form field so typing and scrolling stay native', async () => { - const story = renderStory(); + const workPackage = renderWorkPackage(); const input = document.createElement('input'); - story.appendChild(input); + workPackage.appendChild(input); await nextFrame(); const event = keydown(input, ' '); @@ -140,24 +140,24 @@ describe('Backlogs story controller', () => { }); it('marks the card as selected immediately on click, before the pane opens', async () => { - const story = renderStory(); + const workPackage = renderWorkPackage(); await nextFrame(); - story.dispatchEvent(new MouseEvent('click', { bubbles: true })); + workPackage.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(story.hasAttribute('data-selected')).toBe(true); - expect(story.hasAttribute('aria-current')).toBe(false); + expect(workPackage.hasAttribute('data-selected')).toBe(true); + expect(workPackage.hasAttribute('aria-current')).toBe(false); expect(navigation.openSplitPane).not.toHaveBeenCalled(); }); it('cancels a pending click activation when the card disconnects', async () => { - const story = renderStory(); + const workPackage = renderWorkPackage(); await nextFrame(); vi.useFakeTimers(); try { - story.dispatchEvent(new MouseEvent('click', { bubbles: true })); + workPackage.dispatchEvent(new MouseEvent('click', { bubbles: true })); fixture.remove(); await Promise.resolve(); await Promise.resolve(); @@ -170,45 +170,45 @@ describe('Backlogs story controller', () => { }); it('marks the card as selected when Enter opens the split pane', async () => { - const story = renderStory(); + const workPackage = renderWorkPackage(); await nextFrame(); - keydown(story, 'Enter'); + keydown(workPackage, 'Enter'); - expect(story.hasAttribute('data-selected')).toBe(true); + expect(workPackage.hasAttribute('data-selected')).toBe(true); expect(navigation.openSplitPane).toHaveBeenCalledTimes(1); }); - it('clears the selection of other stories when a card is selected', async () => { - const story = renderStory(); + it('clears the selection of other work packages when a card is selected', async () => { + const workPackage = renderWorkPackage(); const other = document.createElement('article'); - other.setAttribute('data-controller', 'backlogs--story'); + other.setAttribute('data-controller', 'backlogs--work-package'); fixture.appendChild(other); await nextFrame(); other.setAttribute('data-selected', ''); - story.dispatchEvent(new MouseEvent('click', { bubbles: true })); + workPackage.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(other.hasAttribute('data-selected')).toBe(false); - expect(story.hasAttribute('data-selected')).toBe(true); + expect(workPackage.hasAttribute('data-selected')).toBe(true); }); it('syncs selection and aria-current from the visited URL', async () => { - const story = renderStory(); + const workPackage = renderWorkPackage(); await nextFrame(); document.dispatchEvent(new CustomEvent('turbo:visit', { detail: { url: '/projects/demo/backlogs/details/SP-42' }, })); - expect(story.getAttribute('aria-current')).toBe('true'); - expect(story.hasAttribute('data-selected')).toBe(true); + expect(workPackage.getAttribute('aria-current')).toBe('true'); + expect(workPackage.hasAttribute('data-selected')).toBe(true); document.dispatchEvent(new CustomEvent('turbo:visit', { detail: { url: '/projects/demo/backlogs' }, })); - expect(story.hasAttribute('aria-current')).toBe(false); - expect(story.hasAttribute('data-selected')).toBe(false); + expect(workPackage.hasAttribute('aria-current')).toBe(false); + expect(workPackage.hasAttribute('data-selected')).toBe(false); }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.ts similarity index 95% rename from frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts rename to frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.ts index 5fe0d8415b0d..74abf21c9937 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.ts @@ -33,7 +33,7 @@ import { WP_ID_URL_PATTERN } from 'core-app/shared/helpers/work-package-id-patte const DETAILS_URL_PATTERN = new RegExp(`/details/(${WP_ID_URL_PATTERN})(?:/|$)`); -export default class StoryController extends Controller implements EventListenerObject { +export default class WorkPackageController extends Controller implements EventListenerObject { static values = { id: Number, displayId: String, @@ -89,11 +89,11 @@ export default class StoryController extends Controller implements // Selection is applied synchronously on activation, before the split screen // has loaded, so the list reacts immediately. With single selection any - // previously selected story is cleared right away instead of waiting for - // its own URL sync. + // previously selected work package is cleared right away instead of waiting + // for its own URL sync. markAsSelected():void { document - .querySelectorAll('[data-controller~="backlogs--story"][data-selected]') + .querySelectorAll('[data-controller~="backlogs--work-package"][data-selected]') .forEach((other) => { if (other !== this.element) { other.removeAttribute('data-selected'); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index 93bff5d62993..41b947853648 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -467,9 +467,9 @@ describe('Sortable lists item controller', () => { >
                                                  {
                                                • @@ -487,7 +487,7 @@ describe('Sortable lists item controller', () => { return { row: fixture.querySelector('.Box-row')!, - article: fixture.querySelector('[data-controller="backlogs--story"]')!, + article: fixture.querySelector('[data-controller="backlogs--work-package"]')!, }; } @@ -598,7 +598,7 @@ describe('Sortable lists item controller', () => { expect(preview.hasAttribute('aria-disabled')).toBe(false); expect(preview.querySelector('[data-controller]')).toBeNull(); expect(preview.querySelector('[data-action]')).toBeNull(); - expect(preview.querySelector('[data-backlogs--story-target]')).toBeNull(); + expect(preview.querySelector('[data-backlogs--work-package-target]')).toBeNull(); }); it('offsets the preview so the pointer keeps its grab position on the card', async () => { diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb index de77520576e8..d38e725e05e2 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb @@ -69,11 +69,11 @@ def card_arguments def card_data data = { story: true, - controller: "backlogs--story", - backlogs__story_id_value: work_package.id, - backlogs__story_display_id_value: work_package.display_id, - backlogs__story_split_url_value: split_url, - backlogs__story_full_url_value: full_url + controller: "backlogs--work-package", + backlogs__work_package_id_value: work_package.id, + backlogs__work_package_display_id_value: work_package.display_id, + backlogs__work_package_split_url_value: split_url, + backlogs__work_package_full_url_value: full_url } return data unless draggable? diff --git a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb index 2418453fc6b6..bceb533bf310 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb @@ -33,7 +33,7 @@ See COPYRIGHT and LICENSE files for more details. id: dom_target(work_package, :menu, :open_details), tag: :button, label: t(:"js.button_open_details"), - content_arguments: { data: { action: "backlogs--story#openSplitPane" } } + content_arguments: { data: { action: "backlogs--work-package#openSplitPane" } } ) do |item| item.with_leading_visual_icon(icon: :"op-view-split") end diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index 9781b71c2b9d..3c183bae94be 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -143,11 +143,11 @@ def render_component end expect(rendered_component).to have_css(".op-work-package-card") do |card| - expect(card["data-controller"]).to eq("backlogs--story") + expect(card["data-controller"]).to eq("backlogs--work-package") expect(card["data-sortable-lists--item-target"]).to eq("preview handle") - expect(card["data-backlogs--story-split-url-value"]) + expect(card["data-backlogs--work-package-split-url-value"]) .to end_with(project_backlogs_backlog_details_path(project, work_package)) - expect(card["data-backlogs--story-full-url-value"]) + expect(card["data-backlogs--work-package-full-url-value"]) .to end_with(work_package_path(work_package)) end end @@ -178,7 +178,7 @@ def render_component it "includes all=true in the split-view URL" do expect(rendered_component).to have_css(".op-work-package-card") do |card| - expect(card["data-backlogs--story-split-url-value"]).to include("all=true") + expect(card["data-backlogs--work-package-split-url-value"]).to include("all=true") end end end diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index 30f067e753ee..86ae5ab0b905 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -123,9 +123,9 @@ def menu_items end expect(rendered_component).to have_css(".Box-row#work_package_#{work_package1.id} .op-work-package-card") do |card| - expect(card["data-controller"]).to eq("backlogs--story") + expect(card["data-controller"]).to eq("backlogs--work-package") expect(card["data-sortable-lists--item-target"]).to eq("preview handle") - expect(card["data-backlogs--story-display-id-value"]).to eq(work_package1.display_id.to_s) + expect(card["data-backlogs--work-package-display-id-value"]).to eq(work_package1.display_id.to_s) end end diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_component_spec.rb index f4d711a87dee..fb04bf129449 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_component_spec.rb @@ -72,9 +72,9 @@ end it "forwards extra system arguments to the common card root" do - rendered = render_inline(described_class.new(work_package:, menu_src:, data: { controller: "backlogs--story" })) + rendered = render_inline(described_class.new(work_package:, menu_src:, data: { controller: "backlogs--work-package" })) - expect(rendered).to have_css("article[data-controller='backlogs--story']") + expect(rendered).to have_css("article[data-controller='backlogs--work-package']") end it "supports inline menu items through the menu slot" do diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb index 8361e9be7b54..def10b3239f7 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb @@ -106,7 +106,7 @@ def render_component(work_packages:, container:, drag_and_drop:) expect(rendered_component).to have_css( ".Box-row#work_package_#{work_package.id}[data-controller='sortable-lists--item'] " \ - ".op-work-package-card[data-controller='backlogs--story']" + ".op-work-package-card[data-controller='backlogs--work-package']" ) end @@ -120,7 +120,7 @@ def render_component(work_packages:, container:, drag_and_drop:) expect(rendered_component).to have_css(".Box-row#work_package_#{work_package.id} .op-work-package-card") do |card| expect(card["data-story"]).to be_present - expect(card["data-backlogs--story-id-value"]).to eq(work_package.id.to_s) + expect(card["data-backlogs--work-package-id-value"]).to eq(work_package.id.to_s) expect(card["data-sortable-lists--item-target"]).to eq("preview handle") end end diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb index 6649bd56114d..6b957bab192e 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb @@ -104,7 +104,7 @@ render_inline(item.card) card = page.find(".op-work-package-card") - expect(card["data-backlogs--story-split-url-value"]) + expect(card["data-backlogs--work-package-split-url-value"]) .to end_with(project_backlogs_backlog_details_path(project, work_package)) end @@ -120,8 +120,8 @@ semantic_id = work_package.reload.identifier expect(semantic_id).to start_with("STORY-") - expect(card["data-backlogs--story-split-url-value"]).to end_with("/details/#{semantic_id}") - expect(card["data-backlogs--story-display-id-value"]).to eq(semantic_id) + expect(card["data-backlogs--work-package-split-url-value"]).to end_with("/details/#{semantic_id}") + expect(card["data-backlogs--work-package-display-id-value"]).to eq(semantic_id) end end end @@ -140,12 +140,12 @@ ) end - it "wires the card as a Backlogs story" do + it "wires the card as a Backlogs work package" do expect(rendered_card).to have_css( - ".op-work-package-card[data-controller~='backlogs--story']" \ - "[data-backlogs--story-id-value='#{work_package.id}']" \ - "[data-backlogs--story-display-id-value='#{work_package.display_id}']" \ - "[data-backlogs--story-full-url-value='#{work_package_path(work_package)}']" \ + ".op-work-package-card[data-controller~='backlogs--work-package']" \ + "[data-backlogs--work-package-id-value='#{work_package.id}']" \ + "[data-backlogs--work-package-display-id-value='#{work_package.display_id}']" \ + "[data-backlogs--work-package-full-url-value='#{work_package_path(work_package)}']" \ "[data-sortable-lists--item-target='preview handle']" \ "[tabindex='0']" ) @@ -153,7 +153,7 @@ it "does not wire the card as the draggable item" do expect(rendered_card).to have_css( - ".op-work-package-card[data-controller~='backlogs--story']" + ".op-work-package-card[data-controller~='backlogs--work-package']" ) expect(rendered_card).to have_no_css(".op-work-package-card[data-controller~='sortable-lists--item']") expect(rendered_card).to have_no_css(".op-work-package-card[draggable='true']") diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb index f6705a1a0f50..20b3d18d3385 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb @@ -92,7 +92,7 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, expect(page).to have_text(I18n.t(:"js.button_open_details")) expect(page).to have_octicon(:"op-view-split") expect(page).to have_css( - "button[data-action='backlogs--story#openSplitPane']", + "button[data-action='backlogs--work-package#openSplitPane']", text: I18n.t(:"js.button_open_details") ) end From 62e962752c36951b93479986540a7a88823fdb76 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 4 Jul 2026 12:53:31 +0100 Subject: [PATCH 067/105] Regenerate package-lock.json from dev Handle potentially incorrect `package-lock.json` resolutions by restoring the lockfile from `dev` and reconciling it with the current branch: git checkout origin/dev -- package-lock.json npm install --- frontend/package-lock.json | 2957 +++++++++++++++++++++++++++++------- 1 file changed, 2397 insertions(+), 560 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 75cf2a36b0af..24697f9edd3c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -186,11 +186,15 @@ }, "node_modules/@adobe/css-tools": { "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", "dev": true, "license": "MIT" }, "node_modules/@algolia/abtesting": { "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz", + "integrity": "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0", @@ -204,6 +208,8 @@ }, "node_modules/@algolia/client-abtesting": { "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.52.0.tgz", + "integrity": "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0", @@ -217,6 +223,8 @@ }, "node_modules/@algolia/client-analytics": { "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.52.0.tgz", + "integrity": "sha512-9KY36bRl4AH7RjqSeDDOKnjsz4IxQFBEOB8/fWmEbdQe+Isbs5jGzVJu9NEPQ1Tgwxlf8Uf07Swj3jZyMNUZ2g==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0", @@ -230,6 +238,8 @@ }, "node_modules/@algolia/client-common": { "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.52.0.tgz", + "integrity": "sha512-3a/qM3dzJqqfTx7Yrw7uGQ98I3Q0rDfb4Vkv0wEzko96l7YQMxfBVz/VbLq2N+c59GweYv6Vhp8mPeqnWJSITw==", "license": "MIT", "engines": { "node": ">= 14.0.0" @@ -237,6 +247,8 @@ }, "node_modules/@algolia/client-insights": { "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.52.0.tgz", + "integrity": "sha512-Rki7ACbMcvbQW0BuM84x9dkGHY47ABmv4jU6tYssat2k02p3mIUms2YOLUAMeknhmnFsj6lb6ZzOXdMWMyc1sA==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0", @@ -250,6 +262,8 @@ }, "node_modules/@algolia/client-personalization": { "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.52.0.tgz", + "integrity": "sha512-96s4Uzc3kk+/f4jJXIVVGWP5XlngOGNQ1x6hW9AT59pOixHlOs5tqJg+ZUS/GQ6h/iYP0ceQcmxDQeLyCLTaDQ==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0", @@ -263,6 +277,8 @@ }, "node_modules/@algolia/client-query-suggestions": { "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.52.0.tgz", + "integrity": "sha512-lqeycNpSPe5Qa0OUWpejVvYQjQWV5nQuLT0a4aq7XzRAvCxprV/6Lf841EygdD2nrFnuS58ok7Au1uOtXzpnkg==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0", @@ -276,6 +292,8 @@ }, "node_modules/@algolia/client-search": { "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.52.0.tgz", + "integrity": "sha512-ly1wETVGRo30cx61O7fetESN+ElL9c9K+bD/AVgnT1ar4c6v+/Yqjrhdtu6Fm4D0s4NZP081Isf6tunH1wUXHg==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0", @@ -289,6 +307,8 @@ }, "node_modules/@algolia/ingestion": { "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.52.0.tgz", + "integrity": "sha512-U4EeTvgmluRjj39ykZSAd5X+a6LD5m7/mcOWDmB7hqm1R6QY0yT8jLxpNVEjYhzgEN5hcDGW6X67EWQY8KiYGQ==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0", @@ -302,6 +322,8 @@ }, "node_modules/@algolia/monitoring": { "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.52.0.tgz", + "integrity": "sha512-FCPnDcILfpTE94u7BVlV4DmnSV5wE3+j25EEF+3dYPrVzkVCSoAHs318oWDGxnxsAgiL4HpL12Jc4XHmw9shpA==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0", @@ -315,6 +337,8 @@ }, "node_modules/@algolia/recommend": { "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.52.0.tgz", + "integrity": "sha512-br3DO7n4N8CXwTRbZS0MnB4WQ9YHfNjCwkCEzVR/wek/qNTDQKDb0nROmkFaNZ8ucUqUVKZi074dbwMwRDlK8Q==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0", @@ -328,6 +352,8 @@ }, "node_modules/@algolia/requester-browser-xhr": { "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.52.0.tgz", + "integrity": "sha512-b0T/Ca2c9KyEslKsVrGZvbe1UrrKKSdfXhBZ2pbpKahFUzJfziRZ0urbOm7V65O0tO/jwU+Lo/+bIiiyhzGt8w==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0" @@ -338,6 +364,8 @@ }, "node_modules/@algolia/requester-fetch": { "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.52.0.tgz", + "integrity": "sha512-ozBT8J/mtD4H4IAojw8QPirlcL2gHrI1BGuZ4/ZXXO/rTE1yQ4VIPJj4mTTbwo4FbkS1MoJsD/DsrqLzhnc4/g==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0" @@ -348,6 +376,8 @@ }, "node_modules/@algolia/requester-node-http": { "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.52.0.tgz", + "integrity": "sha512-gyyWcLD22tnabmoit4iukCXuoRc5HYJuUjPSEa8a0D/f/NlRafpWi52AlAaa4Uu/rsl7saHsJFTNjTptWbu2+A==", "license": "MIT", "dependencies": { "@algolia/client-common": "5.52.0" @@ -358,6 +388,8 @@ }, "node_modules/@ampproject/remapping": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -386,7 +418,7 @@ } }, "node_modules/@angular-builders/custom-esbuild": { - "version": "22.0.0", + "version": "22.0.1", "resolved": "https://registry.npmjs.org/@angular-builders/custom-esbuild/-/custom-esbuild-22.0.1.tgz", "integrity": "sha512-irWMzAcxmztCSg8Ei6H8+eHYudCdmWJI+tKkaSHiUuQt9uc6W0huuMvKtUMBBqon38w45vX08T9aQMVODybb5Q==", "dev": true, @@ -502,6 +534,8 @@ }, "node_modules/@angular-eslint/builder": { "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-22.0.0.tgz", + "integrity": "sha512-T2vWQYUhJs6iUlgocHV12OgoxbmN63f17a+tgW+3sYrKN0KAB3xuHsPOoYpRYoWqkVVC44HD441Ju4IDvo8vKg==", "dev": true, "license": "MIT", "dependencies": { @@ -516,11 +550,15 @@ }, "node_modules/@angular-eslint/bundled-angular-compiler": { "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-22.0.0.tgz", + "integrity": "sha512-rv15vGDpGW8zZFaLdhQ+iIO1f0bZds/xvuxoX277hFisXp5Kt6FumJNNIb4g/qxq3xsY46a7fD6R7KvGY3smHg==", "dev": true, "license": "MIT" }, "node_modules/@angular-eslint/eslint-plugin": { "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-22.0.0.tgz", + "integrity": "sha512-mKLScPZhqG64ic0KIQoxqSqCdkPwtEZuTOuunvc9lYTw05MJSHRUM2yVFODlCGq97c6BN1F6KBk2I+a+KFnr1g==", "dev": true, "license": "MIT", "dependencies": { @@ -536,6 +574,8 @@ }, "node_modules/@angular-eslint/eslint-plugin-template": { "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-22.0.0.tgz", + "integrity": "sha512-y6XL5HJ8C31NpBvkVHpU3bWc+Rk9g1zRtHrs39omhuT29eEUcS3zu47HMFV6tf8rHOI97B2Mstg6qYS5XL9ATg==", "dev": true, "license": "MIT", "dependencies": { @@ -554,6 +594,8 @@ }, "node_modules/@angular-eslint/schematics": { "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-22.0.0.tgz", + "integrity": "sha512-gsJQx6c+WIWC5d+NAqn4rRdUzwhinUCTNmCM9x4wygV9DrbAfVG+6OFPEbaDMryNvf0HYDcnGclbIbXjukGCaw==", "dev": true, "license": "MIT", "dependencies": { @@ -571,6 +613,8 @@ }, "node_modules/@angular-eslint/schematics/node_modules/ignore": { "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -579,6 +623,8 @@ }, "node_modules/@angular-eslint/schematics/node_modules/semver": { "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -590,6 +636,8 @@ }, "node_modules/@angular-eslint/template-parser": { "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-22.0.0.tgz", + "integrity": "sha512-jU5MKQ24bBB4J99gSSexmUrLm2LvTJZCuCHhNTQ1LavWX4e1lrIxhm+6pJILOm6Cixf8jyNXnHMty6nljX8J+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -603,6 +651,8 @@ }, "node_modules/@angular-eslint/utils": { "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-22.0.0.tgz", + "integrity": "sha512-VFodMojghnPYm+B3U+HRYrqebPMj8NyobNjVzDdY8V5XIBW+4ivOSEINIz81G48rmm/NZKwj56+bJ88bVX4KIw==", "dev": true, "license": "MIT", "dependencies": { @@ -616,6 +666,9 @@ }, "node_modules/@angular/animations": { "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-22.0.2.tgz", + "integrity": "sha512-l9lVG9k+baFMWXNsFUxwmxQaUZMkpkTn3vRpa1hs/vABzT/KnaDeweDtvvkS0NS1RYJenoxhONlMNEWuJ4VR1A==", + "deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1173,6 +1226,8 @@ }, "node_modules/@angular/build/node_modules/agent-base": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", "dev": true, "license": "MIT", "engines": { @@ -1223,6 +1278,8 @@ }, "node_modules/@angular/build/node_modules/https-proxy-agent": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", + "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", "dev": true, "license": "MIT", "dependencies": { @@ -1235,6 +1292,8 @@ }, "node_modules/@angular/build/node_modules/picomatch": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -1246,6 +1305,8 @@ }, "node_modules/@angular/build/node_modules/tinyglobby": { "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { @@ -1261,6 +1322,8 @@ }, "node_modules/@angular/cdk": { "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-22.0.2.tgz", + "integrity": "sha512-3AOyLNIpvXkxbiCeUc4R5ubwCBpY83ZPe2I6Q/cTUW53SnFapEBNYZ2spSY+jPVY4IVPnQN1Tvjlzq6R9K4M3w==", "license": "MIT", "dependencies": { "parse5": "^8.0.0", @@ -1309,6 +1372,8 @@ }, "node_modules/@angular/cli/node_modules/ansi-regex": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "license": "MIT", "engines": { "node": ">=12" @@ -1319,6 +1384,8 @@ }, "node_modules/@angular/cli/node_modules/ansi-styles": { "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -1329,6 +1396,8 @@ }, "node_modules/@angular/cli/node_modules/cliui": { "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "license": "ISC", "dependencies": { "string-width": "^7.2.0", @@ -1341,10 +1410,14 @@ }, "node_modules/@angular/cli/node_modules/emoji-regex": { "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, "node_modules/@angular/cli/node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -1360,6 +1433,8 @@ }, "node_modules/@angular/cli/node_modules/strip-ansi": { "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -1373,6 +1448,8 @@ }, "node_modules/@angular/cli/node_modules/wrap-ansi": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -1388,6 +1465,8 @@ }, "node_modules/@angular/cli/node_modules/yargs": { "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "license": "MIT", "dependencies": { "cliui": "^9.0.1", @@ -1403,6 +1482,8 @@ }, "node_modules/@angular/cli/node_modules/yargs-parser": { "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" @@ -1410,6 +1491,8 @@ }, "node_modules/@angular/common": { "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.0.2.tgz", + "integrity": "sha512-XSkHYRwrM54v4GZ+fg9KU1KbSIE/iQF33VXKo5zqVNKO11MnAbJ59qzyqX/5EzSeogHyBoHApprFKACsCAKm/Q==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1424,6 +1507,8 @@ }, "node_modules/@angular/compiler": { "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.0.2.tgz", + "integrity": "sha512-5G+h/4/iCfqdTBsSgjB46Oe4oC6jXutCpFc5JYWRpnJWsbp3UfwRhwGVWIV1DBPnR8H/3QZzteRP1jINiH5+hg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1433,9 +1518,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "22.0.4", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.0.4.tgz", - "integrity": "sha512-UmZRpw3pStRmH3ZOLg3BfyGaTxu/aqL00U1eoE3yfsUea25zJLU+gC4vx3NYtHzYKQu84wX3gTZXhLuuYYTa8w==", + "version": "22.0.3", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.0.3.tgz", + "integrity": "sha512-TMpKn2KefhGXM1FzcLYeyTMsBbxtFJU83bYnlI0pTw7k3jBAmN6H+ydRcNhdEYr3R2Ja3ncOXGV98RWLm9ElcQ==", "license": "MIT", "dependencies": { "@babel/core": "7.29.7", @@ -1455,7 +1540,7 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/compiler": "22.0.4", + "@angular/compiler": "22.0.3", "typescript": ">=6.0 <6.1" }, "peerDependenciesMeta": { @@ -1511,6 +1596,8 @@ }, "node_modules/@angular/compiler-cli/node_modules/ansi-regex": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "license": "MIT", "engines": { "node": ">=12" @@ -1521,6 +1608,8 @@ }, "node_modules/@angular/compiler-cli/node_modules/ansi-styles": { "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "license": "MIT", "engines": { "node": ">=12" @@ -1531,6 +1620,8 @@ }, "node_modules/@angular/compiler-cli/node_modules/chokidar": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -1544,6 +1635,8 @@ }, "node_modules/@angular/compiler-cli/node_modules/cliui": { "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "license": "ISC", "dependencies": { "string-width": "^7.2.0", @@ -1556,10 +1649,14 @@ }, "node_modules/@angular/compiler-cli/node_modules/emoji-regex": { "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, "node_modules/@angular/compiler-cli/node_modules/readdirp": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -1571,6 +1668,8 @@ }, "node_modules/@angular/compiler-cli/node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -1586,6 +1685,8 @@ }, "node_modules/@angular/compiler-cli/node_modules/strip-ansi": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -1599,6 +1700,8 @@ }, "node_modules/@angular/compiler-cli/node_modules/wrap-ansi": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -1614,6 +1717,8 @@ }, "node_modules/@angular/compiler-cli/node_modules/yargs": { "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "license": "MIT", "dependencies": { "cliui": "^9.0.1", @@ -1629,6 +1734,8 @@ }, "node_modules/@angular/compiler-cli/node_modules/yargs-parser": { "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" @@ -1636,6 +1743,8 @@ }, "node_modules/@angular/core": { "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.0.2.tgz", + "integrity": "sha512-YMs6OZNeXh4tg67ePwSRN426WYvjqGdjxEwLrdOONKAruOmJAzW/Tqe328k/4SHfdbJTR87GPpRi5FzVP43DRA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1659,6 +1768,8 @@ }, "node_modules/@angular/elements": { "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/elements/-/elements-22.0.2.tgz", + "integrity": "sha512-ATwjZcmVAwZWm/HsK33hKEjII8xVUQTYvvwtiXQr+RXXb/0CXrxPoUDWXutZmpY2q0Etc4F7pHiVNCT91nNS4A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1673,6 +1784,8 @@ }, "node_modules/@angular/forms": { "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.0.2.tgz", + "integrity": "sha512-k2WhkE8Of8/JRYEojSgfygiXbP6I7f/yZ/jgJzFGRC1FlF5w5erQMFx8KPg1J5CRE8kYPzW8rM4tSVCq7AaDUg==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1691,6 +1804,8 @@ }, "node_modules/@angular/language-service": { "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-22.0.2.tgz", + "integrity": "sha512-J7QXjv9R/wFVwK4CZBjzE6B1owFQTKsb0KWVhuuPcglD3jtVAJ5xipUI9gco93GmCT3EU9oANmN8jQcAn67h4A==", "dev": true, "license": "MIT", "engines": { @@ -1699,6 +1814,8 @@ }, "node_modules/@angular/platform-browser": { "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.0.2.tgz", + "integrity": "sha512-xUkpJo/Jwa7rgpoSnZs5TeuOD3SDQL+CPJrMGjHivsqWMcAqzSNnIOcbNDJRSxAYkZ9zlJ1+h39JWSUk99rRBw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1719,6 +1836,9 @@ }, "node_modules/@angular/platform-browser-dynamic": { "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-22.0.2.tgz", + "integrity": "sha512-5jDZzbesBBPCt41oq166B23TCW4ue9ZJyX4KlSRpGP/x8fjPGF22+AKASU6OPRnCNmmUsNk8DpenaBj+eFg/Sw==", + "deprecated": "@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1735,6 +1855,8 @@ }, "node_modules/@angular/router": { "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.0.2.tgz", + "integrity": "sha512-uiYlcbOyBliFq1v7O3nMyZtM8scDBurjk4AU2wEPWxSVAXuEjyfnAvowyPzVzGYAEKrsYtcg2TWSsQraqHUbnA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1751,6 +1873,8 @@ }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", "license": "MIT", "dependencies": { "@jsdevtools/ono": "^7.1.3", @@ -1761,27 +1885,32 @@ }, "node_modules/@appsignal/javascript": { "version": "1.6.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@appsignal/javascript/-/javascript-1.6.1.tgz", + "integrity": "sha512-jRVkTYsLC7tM5no6V2Fw2Vc325KRKductkEEHzXTS+upZdBgzkxeTQ1MjuPcfN73vUEplaaP6tMkqv7Ids6frg==", "dependencies": { "tslib": "^2.3.0" } }, "node_modules/@appsignal/plugin-breadcrumbs-console": { "version": "1.1.37", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@appsignal/plugin-breadcrumbs-console/-/plugin-breadcrumbs-console-1.1.37.tgz", + "integrity": "sha512-l8TKLJd6GScUWrlLaBysdT8Q3PyaO1RQjkmCMolN3aCNxTx4C9zw3f+CIlRITaW2sTd8LRN1NV6ee3VEMaT6nQ==", "dependencies": { "@appsignal/javascript": "=1.6.1" } }, "node_modules/@appsignal/plugin-breadcrumbs-network": { "version": "1.1.24", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@appsignal/plugin-breadcrumbs-network/-/plugin-breadcrumbs-network-1.1.24.tgz", + "integrity": "sha512-7RYADVRYHKNuunma3cN38xP40k3BpB5pkkDc3IIePpVe8QDxlTErzgSa9SRDp69ItNr8rU9VUJWqBZb0jnF1Cg==", "dependencies": { "@appsignal/javascript": "=1.6.1" } }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", "dependencies": { @@ -1797,6 +1926,8 @@ }, "node_modules/@asamuzakjp/dom-selector": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1812,6 +1943,8 @@ }, "node_modules/@asamuzakjp/generational-cache": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", "dev": true, "license": "MIT", "engines": { @@ -1820,6 +1953,8 @@ }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "dev": true, "license": "MIT" }, @@ -1856,6 +1991,8 @@ }, "node_modules/@authress/login": { "version": "2.6.417", + "resolved": "https://registry.npmjs.org/@authress/login/-/login-2.6.417.tgz", + "integrity": "sha512-3iMjpwnU3iWTpVjS5nc1/7QrIresFS+TKN43R9UtV/khW2liyq9CPyX5ZCjZ74TWEXOckhTwqy9s7VpHlZ8TWw==", "license": "Apache-2.0", "dependencies": { "cookie": "<1", @@ -1867,6 +2004,8 @@ }, "node_modules/@babel/code-frame": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -1879,6 +2018,8 @@ }, "node_modules/@babel/compat-data": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1886,8 +2027,9 @@ }, "node_modules/@babel/core": { "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1915,13 +2057,15 @@ }, "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" } @@ -1944,6 +2088,8 @@ }, "node_modules/@babel/helper-annotate-as-pure": { "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "dev": true, "license": "MIT", "dependencies": { @@ -1955,6 +2101,8 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { "@babel/compat-data": "^7.29.7", @@ -1969,13 +2117,16 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", - "license": "ISC", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/helper-globals": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1983,6 +2134,8 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -1994,6 +2147,8 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", @@ -2009,6 +2164,8 @@ }, "node_modules/@babel/helper-split-export-declaration": { "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", "dev": true, "license": "MIT", "dependencies": { @@ -2020,6 +2177,8 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2027,6 +2186,8 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2034,6 +2195,8 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2054,6 +2217,8 @@ }, "node_modules/@babel/parser": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -2067,13 +2232,16 @@ }, "node_modules/@babel/runtime": { "version": "7.29.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime-corejs3": { "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz", + "integrity": "sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==", "license": "MIT", "dependencies": { "core-js-pure": "^3.43.0" @@ -2084,6 +2252,8 @@ }, "node_modules/@babel/template": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -2096,6 +2266,8 @@ }, "node_modules/@babel/traverse": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -2112,6 +2284,8 @@ }, "node_modules/@babel/types": { "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -2123,6 +2297,8 @@ }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", "engines": { @@ -2131,11 +2307,15 @@ }, "node_modules/@blazediff/core": { "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", "dev": true, "license": "MIT" }, "node_modules/@blocknote/core": { "version": "0.51.4", + "resolved": "https://registry.npmjs.org/@blocknote/core/-/core-0.51.4.tgz", + "integrity": "sha512-uR7/BlW6VVlCx/JzfGbkaLGz7O5uI3bu2tbNIdzGTfSloiEp8Qc14if36Dy7DDs+dVWfZgwds0jWiGfE3AzJiw==", "license": "MPL-2.0", "dependencies": { "@emoji-mart/data": "^1.2.1", @@ -2169,6 +2349,8 @@ }, "node_modules/@blocknote/mantine": { "version": "0.51.4", + "resolved": "https://registry.npmjs.org/@blocknote/mantine/-/mantine-0.51.4.tgz", + "integrity": "sha512-Ka7QjzhgB/T2ekwtXgwa9zIiiixAm4dob3FklJIedu+9j/eO4auokmRnWAhZQmNJsOdE7mQTUmnv6X4k/lHyCQ==", "license": "MPL-2.0", "dependencies": { "@blocknote/core": "0.51.4", @@ -2184,6 +2366,8 @@ }, "node_modules/@blocknote/react": { "version": "0.51.4", + "resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.51.4.tgz", + "integrity": "sha512-tWe039t/Gkj9YDoEXruclXQvI+toI0ctcyikbHa8dm3Q6zTgy24+T/P1t1fYSNPBkSnhq+ILz2fLLnCK994qkA==", "license": "MPL-2.0", "dependencies": { "@blocknote/core": "0.51.4", @@ -2208,14 +2392,20 @@ }, "node_modules/@blocknote/react/node_modules/@types/use-sync-external-store": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==", "license": "MIT" }, "node_modules/@braintree/sanitize-url": { "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", "license": "MIT" }, "node_modules/@bramus/specificity": { "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "dev": true, "license": "MIT", "dependencies": { @@ -2227,6 +2417,8 @@ }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, "license": "MIT", "dependencies": { @@ -2238,6 +2430,8 @@ }, "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2247,6 +2441,8 @@ }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -2265,6 +2461,8 @@ }, "node_modules/@csstools/css-calc": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", "dev": true, "funding": [ { @@ -2287,6 +2485,8 @@ }, "node_modules/@csstools/css-color-parser": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", "dev": true, "funding": [ { @@ -2313,6 +2513,8 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -2334,6 +2536,8 @@ }, "node_modules/@csstools/css-syntax-patches-for-csstree": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", "dev": true, "funding": [ { @@ -2357,6 +2561,8 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, "funding": [ { @@ -2375,7 +2581,8 @@ }, "node_modules/@datorama/akita": { "version": "8.0.1", - "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@datorama/akita/-/akita-8.0.1.tgz", + "integrity": "sha512-0VnPWd+Sy3ColhzjDSBNcEnzAQtbezk6bYmJHvPaLMK5Ysl90KcNls2bE4sj5vaLeGLjhMtqtfp/RgrigPXDxA==", "peerDependencies": { "rxjs": "*", "tslib": "2.4.1" @@ -2383,10 +2590,14 @@ }, "node_modules/@emoji-mart/data": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", + "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==", "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0" @@ -2394,6 +2605,8 @@ }, "node_modules/@emotion/memoize": { "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { @@ -2466,6 +2679,8 @@ }, "node_modules/@esbuild/darwin-arm64": { "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -2838,6 +3053,8 @@ }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2855,6 +3072,8 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -2863,6 +3082,8 @@ }, "node_modules/@eslint/config-array": { "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2876,6 +3097,8 @@ }, "node_modules/@eslint/config-array/node_modules/balanced-match": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { @@ -2884,6 +3107,8 @@ }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -2895,6 +3120,8 @@ }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -2909,6 +3136,8 @@ }, "node_modules/@eslint/config-helpers": { "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2920,6 +3149,8 @@ }, "node_modules/@eslint/config-helpers/node_modules/@eslint/core": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2931,6 +3162,8 @@ }, "node_modules/@eslint/core": { "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2942,6 +3175,8 @@ }, "node_modules/@eslint/js": { "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { @@ -2961,6 +3196,8 @@ }, "node_modules/@eslint/object-schema": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2969,6 +3206,8 @@ }, "node_modules/@eslint/plugin-kit": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2981,6 +3220,8 @@ }, "node_modules/@exodus/bytes": { "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", "dev": true, "license": "MIT", "engines": { @@ -2997,6 +3238,8 @@ }, "node_modules/@floating-ui/core": { "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.11" @@ -3004,6 +3247,8 @@ }, "node_modules/@floating-ui/dom": { "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.5", @@ -3012,6 +3257,8 @@ }, "node_modules/@floating-ui/react": { "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.1.8", @@ -3025,6 +3272,8 @@ }, "node_modules/@floating-ui/react-dom": { "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.7.6" @@ -3036,17 +3285,22 @@ }, "node_modules/@floating-ui/utils": { "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@fortawesome/fontawesome-free": { "version": "6.7.2", - "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", + "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", "engines": { "node": ">=6" } }, "node_modules/@fullcalendar/angular": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/angular/-/angular-6.1.21.tgz", + "integrity": "sha512-ao2VKdqmu+y++Hyfb5q2qt9vXrNl9twgP74ClhcnVvDWaMNh7ku2QMAfkiYsyUXRY5YX74jP3eWSYAVGrU/rZQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -3059,13 +3313,16 @@ }, "node_modules/@fullcalendar/common": { "version": "5.11.5", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@fullcalendar/common/-/common-5.11.5.tgz", + "integrity": "sha512-3iAYiUbHXhjSVXnYWz27Od2cslztUPsOwiwKlfGvQxBixv2Kl6a8IPwaijKFYJHXdwYmfPoEgK7rvqAGVoIYwA==", "dependencies": { "tslib": "^2.1.0" } }, "node_modules/@fullcalendar/core": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz", + "integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==", "license": "MIT", "dependencies": { "preact": "~10.12.1" @@ -3073,6 +3330,8 @@ }, "node_modules/@fullcalendar/daygrid": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz", + "integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==", "license": "MIT", "peerDependencies": { "@fullcalendar/core": "~6.1.21" @@ -3080,6 +3339,8 @@ }, "node_modules/@fullcalendar/interaction": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.21.tgz", + "integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==", "license": "MIT", "peerDependencies": { "@fullcalendar/core": "~6.1.21" @@ -3087,6 +3348,8 @@ }, "node_modules/@fullcalendar/list": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.21.tgz", + "integrity": "sha512-2rpIhs5pJmV7jyk4oX4bckNqurt6iHcsweE3FDYDdNpmRukPrARnyQYcaVFNVw2bnBFeR/jQW/St2MlauxF3GQ==", "license": "MIT", "peerDependencies": { "@fullcalendar/core": "~6.1.21" @@ -3094,6 +3357,8 @@ }, "node_modules/@fullcalendar/moment": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/moment/-/moment-6.1.21.tgz", + "integrity": "sha512-2/IOADv/W9km+MQRtfYyC5lpDsyohd7KP9XxrJ4wWqL54JDfKGbTDjP8irBmyy1Qi2fgcyrdTM5mfFk7erUuIA==", "license": "MIT", "peerDependencies": { "@fullcalendar/core": "~6.1.21", @@ -3102,6 +3367,8 @@ }, "node_modules/@fullcalendar/moment-timezone": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/moment-timezone/-/moment-timezone-6.1.21.tgz", + "integrity": "sha512-1H5voLR6PGiIf+JM6TCWq8oMTMOOqs+Am8lo/3GzHnBPo81RN72h87zm50YgGq9HjIsMdNs7RgdMVAUcXByerQ==", "license": "MIT", "peerDependencies": { "@fullcalendar/core": "~6.1.21", @@ -3110,6 +3377,8 @@ }, "node_modules/@fullcalendar/multimonth": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/multimonth/-/multimonth-6.1.21.tgz", + "integrity": "sha512-/5IZDDcdRzgO/h7V3RBEhzhrza5TIQ2T025IjA2Dfq3xbvxSnMAl2CMcy7VrSHsKaY1eWvxQXg/NZZm7hLmDYA==", "license": "MIT", "dependencies": { "@fullcalendar/daygrid": "~6.1.21" @@ -3120,6 +3389,8 @@ }, "node_modules/@fullcalendar/premium-common": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.21.tgz", + "integrity": "sha512-c1d5e1xyteQiU1aY6QQr5TDOV0qGnHvZy8OQzVbVDjGJEECu9wAZ2Z5GCrMymFgUa5qpL2SXrw7iBkoCeoBs0w==", "license": "SEE LICENSE IN LICENSE.md", "peerDependencies": { "@fullcalendar/core": "~6.1.21" @@ -3127,6 +3398,8 @@ }, "node_modules/@fullcalendar/resource": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/resource/-/resource-6.1.21.tgz", + "integrity": "sha512-sUh28dYbyT7kPmM/fVdJjiX35RpSZ+wlIuG6hojwsXQxA1OiCVW0zbjtc+LAz2XZgj80Lqi+6dxOu+0Jb3aCGQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@fullcalendar/premium-common": "~6.1.21" @@ -3137,7 +3410,8 @@ }, "node_modules/@fullcalendar/resource-common": { "version": "5.11.5", - "license": "SEE LICENSE IN LICENSE.md", + "resolved": "https://registry.npmjs.org/@fullcalendar/resource-common/-/resource-common-5.11.5.tgz", + "integrity": "sha512-r7Jsd9ge65m9AShyWAOUUdRulyOKI3p0a7lOcilQ1KXDy0d1O2KpPa27sqRzpofLoDoSrteVP7dirU8Lg20sTA==", "dependencies": { "@fullcalendar/common": "~5.11.5", "@fullcalendar/premium-common": "~5.11.5", @@ -3146,7 +3420,8 @@ }, "node_modules/@fullcalendar/resource-common/node_modules/@fullcalendar/premium-common": { "version": "5.11.5", - "license": "SEE LICENSE IN LICENSE.md", + "resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-5.11.5.tgz", + "integrity": "sha512-QMLOm+MKxvsbji4wt7YGqB73IqwHD+y2JD1DZ3t0LCq2Ul6QtQjNxRP7/7DklXPI8atXoJRRXzwqRq+8O9FFHw==", "dependencies": { "@fullcalendar/common": "~5.11.5", "tslib": "^2.1.0" @@ -3154,6 +3429,8 @@ }, "node_modules/@fullcalendar/resource-timeline": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/resource-timeline/-/resource-timeline-6.1.21.tgz", + "integrity": "sha512-rhCBhk3S1Gh2/bNcxwptrrQTAfT+3l3xe7AwaExdydcoz/t0aYSZ/naogh/R9OcT7FLrNCapxlHVZE7RcugHeA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@fullcalendar/premium-common": "~6.1.21", @@ -3167,6 +3444,8 @@ }, "node_modules/@fullcalendar/scrollgrid": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/scrollgrid/-/scrollgrid-6.1.21.tgz", + "integrity": "sha512-SmZUixhrJlj0yWagZAbyp+2wQanx3KlbNEqbcN2ym+k2gCimAWmeRTmM3iRfBiSyGYMJ8nAQAbdyXXPODaE9DA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@fullcalendar/premium-common": "~6.1.21" @@ -3177,6 +3456,8 @@ }, "node_modules/@fullcalendar/timegrid": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.21.tgz", + "integrity": "sha512-2DnShx/jallGmb8QCkr6pAOu/zuPhJrP7+uTrAtSnbqsX7GF3lTxqSeNGkTQwsgF5g/ia8udhQ+JNYaE+TN1cQ==", "license": "MIT", "dependencies": { "@fullcalendar/daygrid": "~6.1.21" @@ -3187,6 +3468,8 @@ }, "node_modules/@fullcalendar/timeline": { "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@fullcalendar/timeline/-/timeline-6.1.21.tgz", + "integrity": "sha512-7G/EdoDOdesMlDdVKCm1hoTn1ya45nbKE1jceaURVBAO2l7sG2UHFYC8cUPfi5PNDKo7kzkYBqHvGkgMDj1tXw==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@fullcalendar/premium-common": "~6.1.21", @@ -3198,6 +3481,8 @@ }, "node_modules/@gar/promise-retry": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", "license": "MIT", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -3205,6 +3490,8 @@ }, "node_modules/@github/auto-check-element": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@github/auto-check-element/-/auto-check-element-6.0.0.tgz", + "integrity": "sha512-87mHEywJEtlG/37zFrx4PUgDqczgtv9jrauW3IojNy9y+nALIAm6e2jnWpfgcqeMWSevzph2M6reJoHpuSjyWw==", "license": "MIT", "dependencies": { "@github/mini-throttle": "^2.1.0" @@ -3212,6 +3499,8 @@ }, "node_modules/@github/auto-complete-element": { "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@github/auto-complete-element/-/auto-complete-element-3.8.0.tgz", + "integrity": "sha512-rS2Uj38V1BsenLvrIswV5IXfiYH2/KUhz6inot+JXho/fFOO+01tsW1HxqSdIXqh5EDuoY0f/GQsztZcH22AXQ==", "license": "MIT", "dependencies": { "@github/combobox-nav": "^2.1.7" @@ -3219,53 +3508,76 @@ }, "node_modules/@github/catalyst": { "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@github/catalyst/-/catalyst-1.8.1.tgz", + "integrity": "sha512-dnN4WWpbeuQvA17LvsGdlXEueJdBk9y+I+WO5pdNpoHNOXPsFcz3hJrq1iRmdsNgQOf4S8e83axtwIxvG62eWA==", "license": "MIT" }, "node_modules/@github/clipboard-copy-element": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@github/clipboard-copy-element/-/clipboard-copy-element-1.3.0.tgz", + "integrity": "sha512-wyntkQkwoLbLo+Hqg2LIVMXDIzcvUb9bSDz+clX6nVJItwzh103rHxdXFRZD+DmxVbuEW5xSznYQXkz1jZT+xg==", "license": "MIT" }, "node_modules/@github/combobox-nav": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.3.1.tgz", + "integrity": "sha512-gwxPzLw8XKecy1nP63i9lOBritS3bWmxl02UX6G0TwMQZbMem1BCS1tEZgYd3mkrkiDrUMWaX+DbFCuDFo3K+A==", "license": "MIT" }, "node_modules/@github/details-menu-element": { "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@github/details-menu-element/-/details-menu-element-1.0.13.tgz", + "integrity": "sha512-gMkii86w/oUP5dq8yOWZn1sgbgtFj3AYETxxtpsqRggZktgd8te4+npAn4Hm+936c/lxmEzXqfjARL/CzGR4+w==", "license": "MIT" }, "node_modules/@github/image-crop-element": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@github/image-crop-element/-/image-crop-element-5.0.0.tgz", + "integrity": "sha512-Vgm2OwWAs1ESoib/t5sjxsAYo6YTOxxAjWDRxswX7qrqoyCejTZ3hshdo4Ep5e+Mz/GVTZC3rdMtg06dk/eT4g==", "license": "MIT" }, "node_modules/@github/include-fragment-element": { "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@github/include-fragment-element/-/include-fragment-element-6.4.1.tgz", + "integrity": "sha512-ffgXc7qwBtY/rYcMkAjxZJlyOPFaeC9K1Oc+n7Edwt3BAHPokUSdMfDivb+/dGO+NU2n7l1/L4v5uQN+wBeV4g==", "license": "MIT" }, "node_modules/@github/mini-throttle": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@github/mini-throttle/-/mini-throttle-2.1.1.tgz", + "integrity": "sha512-KtOPaB+FiKJ6jcKm9UKyaM5fPURHGf+xcp+b4Mzoi81hOc6M1sIGpMZMAVbNzfa2lW5+RPGKq888Px0j76OZ/A==", "license": "MIT" }, "node_modules/@github/relative-time-element": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-5.0.0.tgz", + "integrity": "sha512-L/2r0DNR/rMbmHWcsdmhtOiy2gESoGOhItNFD4zJ3nZfHl79Dx3N18Vfx/pYr2lruMOdk1cJZb4wEumm+Dxm1w==", "license": "MIT" }, "node_modules/@github/remote-input-element": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@github/remote-input-element/-/remote-input-element-0.4.0.tgz", + "integrity": "sha512-apsMwsFW24F+w2wzT8oKoBi9lpm6GeFOmtuL+1YwDVmIiwixfHOD3MnEsEOv0RwmHsMdWmIjP9mxWyTWPKZHGg==", "license": "MIT" }, "node_modules/@github/tab-container-element": { "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@github/tab-container-element/-/tab-container-element-3.4.0.tgz", + "integrity": "sha512-Yx70pO8A0p7Stnm9knKkUNX8i4bjuwDYZarRkM8JH0Z+ffhpe++oNAPbzGI9GEcGugRHvKuSC6p4YOdoHtTniQ==", "license": "MIT" }, "node_modules/@github/webauthn-json": { "version": "2.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@github/webauthn-json/-/webauthn-json-2.1.1.tgz", + "integrity": "sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==", "bin": { "webauthn-json": "dist/bin/main.js" } }, "node_modules/@handlewithcare/prosemirror-inputrules": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@handlewithcare/prosemirror-inputrules/-/prosemirror-inputrules-0.1.4.tgz", + "integrity": "sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw==", "license": "MIT", "dependencies": { "prosemirror-history": "^1.4.1", @@ -3279,12 +3591,16 @@ }, "node_modules/@harperfast/extended-iterable": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", + "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", "dev": true, "license": "Apache-2.0", "optional": true }, "node_modules/@hocuspocus/common": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-4.2.0.tgz", + "integrity": "sha512-031clELz+rW7MpmpFUFN7+vE52Gtk1wkrSVHyqydZgKSLf5AtrorliCf38cH65mGGltLR2/JrydzqOk2bVQhjw==", "license": "MIT", "dependencies": { "lib0": "^0.2.87" @@ -3292,6 +3608,8 @@ }, "node_modules/@hocuspocus/provider": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-4.2.0.tgz", + "integrity": "sha512-w38vvgYBs4NDonMkxb4R//n+CgBJ+K/03RMXcr8yHx1I3TY8HYpTlRqOUWdIJpmgw2ZyuM9EdbJwwRPrZF7iEA==", "license": "MIT", "dependencies": { "@hocuspocus/common": "^4.2.0", @@ -3305,6 +3623,8 @@ }, "node_modules/@hono/node-server": { "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -3315,10 +3635,13 @@ }, "node_modules/@hotwired/stimulus": { "version": "3.2.2", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz", + "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==" }, "node_modules/@hotwired/turbo": { "version": "8.0.23", + "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.23.tgz", + "integrity": "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ==", "license": "MIT", "engines": { "node": ">= 18" @@ -3326,6 +3649,8 @@ }, "node_modules/@hotwired/turbo-rails": { "version": "8.0.23", + "resolved": "https://registry.npmjs.org/@hotwired/turbo-rails/-/turbo-rails-8.0.23.tgz", + "integrity": "sha512-iBILwda3qmQC7FYM70+4s6kEQ7Fx9dJ6+yGxjPyrz9a5JDx1+y7OAA5TA7GGVOZJoicMLrKGdFDNorl40X35lw==", "license": "MIT", "dependencies": { "@hotwired/turbo": "^8.0.23", @@ -3334,6 +3659,8 @@ }, "node_modules/@html-eslint/core": { "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@html-eslint/core/-/core-0.62.0.tgz", + "integrity": "sha512-TEWjEE5PcenvXnk27cnQdasR71RDvhlqj6TIu3QHLdpfPo9K11Vx5jWYwM44PS7EaU5+5si2296z72xO1EpLkw==", "dev": true, "license": "MIT", "dependencies": { @@ -3346,6 +3673,8 @@ }, "node_modules/@html-eslint/eslint-plugin": { "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@html-eslint/eslint-plugin/-/eslint-plugin-0.62.0.tgz", + "integrity": "sha512-vtn9fqkI00Q4le7ik7hXopf5VwVJWKQ9Vnle1Jmolvmkb9CKq83DMSWFLCqtlDRkZSi6JKyNIddj6r5Odd+n1w==", "dev": true, "license": "MIT", "dependencies": { @@ -3367,6 +3696,8 @@ }, "node_modules/@html-eslint/parser": { "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@html-eslint/parser/-/parser-0.62.0.tgz", + "integrity": "sha512-kb3uR4AX0hex211zAiiNeCZQVVKm0KbSTjWUKaLGPB9DtoL4y0A4ZJXa9DF2GOIFWDt42xJUUvffB2/MEj3ReA==", "dev": true, "license": "MIT", "dependencies": { @@ -3378,6 +3709,8 @@ }, "node_modules/@html-eslint/template-parser": { "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@html-eslint/template-parser/-/template-parser-0.62.0.tgz", + "integrity": "sha512-sI3Fiy4nsXGsp7k61UA3P/QooqUALE8oz2KuFzXb8Jb3krxlVQWwFXE8Ipr62r53jZyMz8XVsvaMUiP0nwrJsw==", "dev": true, "license": "MIT", "dependencies": { @@ -3387,6 +3720,8 @@ }, "node_modules/@html-eslint/template-syntax-parser": { "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@html-eslint/template-syntax-parser/-/template-syntax-parser-0.62.0.tgz", + "integrity": "sha512-XPYul7JI6ld/a3vKOHmIWvCuNpOBc5YIiei5hHJQcCwLeIUyq3yGNUYyJSVF4k4QXe5ksyPfy0MRtOgA1tZ7Wg==", "dev": true, "license": "MIT", "dependencies": { @@ -3395,6 +3730,8 @@ }, "node_modules/@html-eslint/types": { "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@html-eslint/types/-/types-0.62.0.tgz", + "integrity": "sha512-lG/Pp0m7OTWmB3PBdAtZNwp35dwjhXVY0jwSqP/47sNPRzVmAOVgxWq2tBQYyH6wJYp3aFZiv1wivd9lUAQZvw==", "dev": true, "license": "MIT", "dependencies": { @@ -3405,6 +3742,8 @@ }, "node_modules/@humanfs/core": { "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3413,6 +3752,8 @@ }, "node_modules/@humanfs/node": { "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3425,6 +3766,8 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3437,6 +3780,8 @@ }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3449,6 +3794,8 @@ }, "node_modules/@inquirer/ansi": { "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -3456,6 +3803,8 @@ }, "node_modules/@inquirer/checkbox": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.7", @@ -3477,6 +3826,8 @@ }, "node_modules/@inquirer/confirm": { "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.12.tgz", + "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==", "dev": true, "license": "MIT", "dependencies": { @@ -3497,6 +3848,8 @@ }, "node_modules/@inquirer/core": { "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.7", @@ -3521,6 +3874,8 @@ }, "node_modules/@inquirer/core/node_modules/mute-stream": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -3528,6 +3883,8 @@ }, "node_modules/@inquirer/editor": { "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", "license": "MIT", "dependencies": { "@inquirer/core": "^11.2.1", @@ -3548,6 +3905,8 @@ }, "node_modules/@inquirer/expand": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", "license": "MIT", "dependencies": { "@inquirer/core": "^11.2.1", @@ -3567,6 +3926,8 @@ }, "node_modules/@inquirer/external-editor": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", "license": "MIT", "dependencies": { "chardet": "^2.1.1", @@ -3586,6 +3947,8 @@ }, "node_modules/@inquirer/figures": { "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -3593,6 +3956,8 @@ }, "node_modules/@inquirer/input": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", "license": "MIT", "dependencies": { "@inquirer/core": "^11.2.1", @@ -3612,6 +3977,8 @@ }, "node_modules/@inquirer/number": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", "license": "MIT", "dependencies": { "@inquirer/core": "^11.2.1", @@ -3631,6 +3998,8 @@ }, "node_modules/@inquirer/password": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.7", @@ -3651,6 +4020,8 @@ }, "node_modules/@inquirer/prompts": { "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.4.2.tgz", + "integrity": "sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q==", "license": "MIT", "dependencies": { "@inquirer/checkbox": "^5.1.4", @@ -3678,6 +4049,8 @@ }, "node_modules/@inquirer/prompts/node_modules/@inquirer/confirm": { "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", "license": "MIT", "dependencies": { "@inquirer/core": "^11.2.1", @@ -3697,6 +4070,8 @@ }, "node_modules/@inquirer/rawlist": { "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", "license": "MIT", "dependencies": { "@inquirer/core": "^11.2.1", @@ -3716,6 +4091,8 @@ }, "node_modules/@inquirer/search": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", "license": "MIT", "dependencies": { "@inquirer/core": "^11.2.1", @@ -3736,6 +4113,8 @@ }, "node_modules/@inquirer/select": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", "license": "MIT", "dependencies": { "@inquirer/ansi": "^2.0.7", @@ -3757,6 +4136,8 @@ }, "node_modules/@inquirer/type": { "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -3772,6 +4153,8 @@ }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -3782,6 +4165,8 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3790,6 +4175,8 @@ }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -3798,17 +4185,22 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -3817,15 +4209,20 @@ }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, "node_modules/@knowledgecode/delegate": { "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@knowledgecode/delegate/-/delegate-0.10.4.tgz", + "integrity": "sha512-gdlYxyxyNg2OnRGYTjx89uPxwcv90zy80Z3SNETlth09wV53jdPjMKP2PfvKXLy8mkO/b+lTDpN30O2K6+ZOpw==", "license": "MIT" }, "node_modules/@kolkov/ngx-gallery": { "version": "2.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@kolkov/ngx-gallery/-/ngx-gallery-2.0.1.tgz", + "integrity": "sha512-mTigRy9Ha7bqCF/+GNKeW2Oe8ZILuM5GGMw+ZbvTQWq3X5hngeFFgv8GFG49Py3biX67kb0NhqCP+msLe4wbXQ==", "dependencies": { "tslib": "^2.3.0" }, @@ -3837,14 +4234,19 @@ }, "node_modules/@kurkle/color": { "version": "0.3.2", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" }, "node_modules/@lifeomic/attempt": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.1.0.tgz", + "integrity": "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==", "license": "MIT" }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.3.tgz", + "integrity": "sha512-Co9U3AJ3LW0J8XBHjVoNKA79dMAyFt8EZH3OaKTMcDTj8r+6kG3vSUPq/eGLHT7P0iK3uLaFfhdFYd3033P24g==", "license": "MIT", "dependencies": { "@inquirer/type": "^4.0.5" @@ -3859,10 +4261,13 @@ }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.3.0", - "license": "BSD-3-Clause" + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz", + "integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==" }, "node_modules/@lit/reactive-element": { "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", + "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", "license": "BSD-3-Clause", "dependencies": { "@lit-labs/ssr-dom-shim": "^1.0.0" @@ -3870,6 +4275,8 @@ }, "node_modules/@lmdb/lmdb-darwin-arm64": { "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.4.tgz", + "integrity": "sha512-Kk4Kz3iyu1QiLsLZBS9Af1eSKUC8VR2T+/jyE2iAyuGw2VwK08pp5iTbZnXn6sWu0LogO/RFktMxOjiDA2sS3w==", "cpu": [ "arm64" ], @@ -3966,7 +4373,8 @@ }, "node_modules/@loaders.gl/core": { "version": "4.3.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz", + "integrity": "sha512-cG0C5fMZ1jyW6WCsf4LoHGvaIAJCEVA/ioqKoYRwoSfXkOf+17KupK1OUQyUCw5XoRn+oWA1FulJQOYlXnb9Gw==", "dependencies": { "@loaders.gl/loader-utils": "4.3.4", "@loaders.gl/schema": "4.3.4", @@ -3976,7 +4384,8 @@ }, "node_modules/@loaders.gl/draco": { "version": "4.3.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@loaders.gl/draco/-/draco-4.3.4.tgz", + "integrity": "sha512-4Lx0rKmYENGspvcgV5XDpFD9o+NamXoazSSl9Oa3pjVVjo+HJuzCgrxTQYD/3JvRrolW/QRehZeWD/L/cEC6mw==", "dependencies": { "@loaders.gl/loader-utils": "4.3.4", "@loaders.gl/schema": "4.3.4", @@ -3989,7 +4398,8 @@ }, "node_modules/@loaders.gl/gltf": { "version": "4.3.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@loaders.gl/gltf/-/gltf-4.3.4.tgz", + "integrity": "sha512-EiUTiLGMfukLd9W98wMpKmw+hVRhQ0dJ37wdlXK98XPeGGB+zTQxCcQY+/BaMhsSpYt/OOJleHhTfwNr8RgzRg==", "dependencies": { "@loaders.gl/draco": "4.3.4", "@loaders.gl/images": "4.3.4", @@ -4004,7 +4414,8 @@ }, "node_modules/@loaders.gl/images": { "version": "4.3.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.4.tgz", + "integrity": "sha512-qgc33BaNsqN9cWa/xvcGvQ50wGDONgQQdzHCKDDKhV2w/uptZoR5iofJfuG8UUV2vUMMd82Uk9zbopRx2rS4Ag==", "dependencies": { "@loaders.gl/loader-utils": "4.3.4" }, @@ -4014,7 +4425,8 @@ }, "node_modules/@loaders.gl/las": { "version": "4.3.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@loaders.gl/las/-/las-4.3.4.tgz", + "integrity": "sha512-KGyVuSQwpnVO/RCHYF8ITkDd7DpXnhAPDpgGMCBrpWvBubAbIY38oP0uGecNO/HVfRVAovDEobZICN3uQx2Weg==", "dependencies": { "@loaders.gl/loader-utils": "4.3.4", "@loaders.gl/schema": "4.3.4", @@ -4026,7 +4438,8 @@ }, "node_modules/@loaders.gl/loader-utils": { "version": "4.3.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.3.4.tgz", + "integrity": "sha512-tjMZvlKQSaMl2qmYTAxg+ySR6zd6hQn5n3XaU8+Ehp90TD3WzxvDKOMNDqOa72fFmIV+KgPhcmIJTpq4lAdC4Q==", "dependencies": { "@loaders.gl/schema": "4.3.4", "@loaders.gl/worker-utils": "4.3.4", @@ -4039,7 +4452,8 @@ }, "node_modules/@loaders.gl/schema": { "version": "4.3.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz", + "integrity": "sha512-1YTYoatgzr/6JTxqBLwDiD3AVGwQZheYiQwAimWdRBVB0JAzych7s1yBuE0CVEzj4JDPKOzVAz8KnU1TiBvJGw==", "dependencies": { "@types/geojson": "^7946.0.7" }, @@ -4049,7 +4463,8 @@ }, "node_modules/@loaders.gl/textures": { "version": "4.3.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@loaders.gl/textures/-/textures-4.3.4.tgz", + "integrity": "sha512-arWIDjlE7JaDS6v9by7juLfxPGGnjT9JjleaXx3wq/PTp+psLOpGUywHXm38BNECos3MFEQK3/GFShWI+/dWPw==", "dependencies": { "@loaders.gl/images": "4.3.4", "@loaders.gl/loader-utils": "4.3.4", @@ -4065,13 +4480,16 @@ }, "node_modules/@loaders.gl/worker-utils": { "version": "4.3.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.4.tgz", + "integrity": "sha512-EbsszrASgT85GH3B7jkx7YXfQyIYo/rlobwMx6V3ewETapPUwdSAInv+89flnk5n2eu2Lpdeh+2zS6PvqbL2RA==", "peerDependencies": { "@loaders.gl/core": "^4.3.0" } }, "node_modules/@mantine/core": { "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.3.2.tgz", + "integrity": "sha512-Upy/Z9Sj2eW2dGrFgUy/2kISVsxMTBYTDfP2TFdsIA3PdPSBkdqfSOPX/ug3d3F3a4bnwQSqcO6+aPEpBIh8dg==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.27.19", @@ -4088,6 +4506,8 @@ }, "node_modules/@mantine/hooks": { "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.3.2.tgz", + "integrity": "sha512-jOjpUe0x1A/k3XUiu2/aaSCasXRI5ZKZOucm3ypwsvm9F2u8C1xzwBvagrzlbNZwJRF5vNYIJtCaT7NunPyc0A==", "license": "MIT", "peerDependencies": { "react": "^19.2.0" @@ -4095,6 +4515,8 @@ }, "node_modules/@mantine/utils": { "version": "6.0.22", + "resolved": "https://registry.npmjs.org/@mantine/utils/-/utils-6.0.22.tgz", + "integrity": "sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ==", "license": "MIT", "peerDependencies": { "react": ">=16.8.0" @@ -4102,17 +4524,21 @@ }, "node_modules/@math.gl/core": { "version": "4.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz", + "integrity": "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==", "dependencies": { "@math.gl/types": "4.1.0" } }, "node_modules/@math.gl/types": { "version": "4.1.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz", + "integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==" }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -4151,6 +4577,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { "mime-types": "^3.0.0", @@ -4162,6 +4590,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -4177,6 +4607,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -4199,6 +4631,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "engines": { "node": ">=18" @@ -4210,6 +4644,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", "engines": { "node": ">=6.6.0" @@ -4217,6 +4653,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/express": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", @@ -4258,6 +4696,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -4265,6 +4705,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -4272,6 +4714,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", "engines": { "node": ">=18" @@ -4282,6 +4726,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -4289,6 +4735,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -4303,6 +4751,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -4316,6 +4766,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/send": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -4340,6 +4792,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -4357,6 +4811,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { "content-type": "^2.0.0", @@ -4373,6 +4829,8 @@ }, "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "license": "MIT", "engines": { "node": ">=18" @@ -4384,6 +4842,8 @@ }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], @@ -4608,9 +5068,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4628,9 +5085,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4648,9 +5102,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4668,9 +5119,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4688,9 +5136,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4708,9 +5153,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4728,9 +5170,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4810,6 +5249,8 @@ }, "node_modules/@ng-select/ng-option-highlight": { "version": "23.0.1", + "resolved": "https://registry.npmjs.org/@ng-select/ng-option-highlight/-/ng-option-highlight-23.0.1.tgz", + "integrity": "sha512-RxoppURjVpvMq6BOvmmmk2UrbGOPt5XMsgH/i74p1nHu1ePPyzdQXwgbTDKKIvmvsyBAbwqsqQQxyjXJgRlv0g==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -4821,6 +5262,8 @@ }, "node_modules/@ng-select/ng-select": { "version": "23.0.1", + "resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-23.0.1.tgz", + "integrity": "sha512-3fmPAAmGnix3VAIQB3GZegc2lH6+MeMAmZHBGxAElTIqoMWvJx04cvb7q/V47VcWWposSkV2afcfh4jtGGhuZw==", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -4836,7 +5279,8 @@ }, "node_modules/@ngneat/content-loader": { "version": "7.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@ngneat/content-loader/-/content-loader-7.0.0.tgz", + "integrity": "sha512-CfwsDxh2hsQfUwca3SWnyjQD5jcG6Vb6Ew0JIztj/spr3YIC9Obz7AqEVMDYPKch4QzPJiJ6OVRuLRM2DKiG9g==", "dependencies": { "tslib": "^2.3.0" }, @@ -4846,6 +5290,8 @@ }, "node_modules/@npmcli/agent": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", "license": "ISC", "dependencies": { "agent-base": "^7.1.0", @@ -4860,6 +5306,8 @@ }, "node_modules/@npmcli/agent/node_modules/lru-cache": { "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -4867,6 +5315,8 @@ }, "node_modules/@npmcli/fs": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", "license": "ISC", "dependencies": { "semver": "^7.3.5" @@ -4877,6 +5327,8 @@ }, "node_modules/@npmcli/git": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", @@ -4894,6 +5346,8 @@ }, "node_modules/@npmcli/git/node_modules/isexe": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "license": "BlueOak-1.0.0", "engines": { "node": ">=20" @@ -4901,6 +5355,8 @@ }, "node_modules/@npmcli/git/node_modules/lru-cache": { "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -4908,6 +5364,8 @@ }, "node_modules/@npmcli/git/node_modules/which": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "license": "ISC", "dependencies": { "isexe": "^4.0.0" @@ -4921,6 +5379,8 @@ }, "node_modules/@npmcli/installed-package-contents": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", + "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", "license": "ISC", "dependencies": { "npm-bundled": "^5.0.0", @@ -4935,6 +5395,8 @@ }, "node_modules/@npmcli/node-gyp": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", + "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -4942,6 +5404,8 @@ }, "node_modules/@npmcli/package-json": { "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", "license": "ISC", "dependencies": { "@npmcli/git": "^7.0.0", @@ -4958,6 +5422,8 @@ }, "node_modules/@npmcli/promise-spawn": { "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", + "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", "license": "ISC", "dependencies": { "which": "^6.0.0" @@ -4968,6 +5434,8 @@ }, "node_modules/@npmcli/promise-spawn/node_modules/isexe": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "license": "BlueOak-1.0.0", "engines": { "node": ">=20" @@ -4975,6 +5443,8 @@ }, "node_modules/@npmcli/promise-spawn/node_modules/which": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "license": "ISC", "dependencies": { "isexe": "^4.0.0" @@ -4988,6 +5458,8 @@ }, "node_modules/@npmcli/redact": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -4995,6 +5467,8 @@ }, "node_modules/@npmcli/run-script": { "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", "license": "ISC", "dependencies": { "@npmcli/node-gyp": "^5.0.0", @@ -5009,10 +5483,14 @@ }, "node_modules/@oddbird/popover-polyfill": { "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@oddbird/popover-polyfill/-/popover-polyfill-0.5.2.tgz", + "integrity": "sha512-iFrvar5SOMtKFOSjYvs4z9UlLqDdJbMx0mgISLcPedv+g0ac5sgeETLGtipHCVIae6HJPclNEH5aCyD1RZaEHw==", "license": "BSD-3-Clause" }, "node_modules/@openproject/octicons-angular": { "version": "19.35.0", + "resolved": "https://registry.npmjs.org/@openproject/octicons-angular/-/octicons-angular-19.35.0.tgz", + "integrity": "sha512-oN6bkZeOcrWUAJtfuXsMHmHWpuJMxIt1gvToJpsDgOXFY9Wj1DVO2Di/hMYgG/8k+xv2UZ3kAgks43ENImgLmw==", "dependencies": { "tslib": "^2.3.0" }, @@ -5047,6 +5525,8 @@ }, "node_modules/@openproject/reactivestates": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@openproject/reactivestates/-/reactivestates-3.0.1.tgz", + "integrity": "sha512-Ov9TyxGCDSPU6Uv83bUN7dEn8ntAZsFAioPtZ0F6ZBJ+OU7NS7raDz/5gGjbmk8ESRcmcZvWSmncRHG2rj74Fw==", "dependencies": { "rxjs": "^7.8.0" }, @@ -5056,9 +5536,10 @@ }, "node_modules/@parcel/watcher": { "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", "dev": true, "hasInstallScript": true, - "license": "MIT", "optional": true, "dependencies": { "detect-libc": "^2.0.3", @@ -5112,11 +5593,12 @@ }, "node_modules/@parcel/watcher-darwin-arm64": { "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -5179,9 +5661,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5203,9 +5682,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5227,9 +5703,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5251,9 +5724,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5275,9 +5745,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5299,9 +5766,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5380,14 +5844,16 @@ }, "node_modules/@parcel/watcher/node_modules/node-addon-api": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, - "license": "MIT", "optional": true }, "node_modules/@parcel/watcher/node_modules/picomatch": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, - "license": "MIT", "optional": true, "engines": { "node": ">=12" @@ -5398,16 +5864,21 @@ }, "node_modules/@polka/url": { "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true, "license": "MIT" }, "node_modules/@primer/behaviors": { "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.10.2.tgz", + "integrity": "sha512-93juWZbWg2DRhC11+7RT7hMpY1VD3lBosLmccqEZ65yrCHqkBCjI8Uj8wxs3y0U+wWE07LAoLHAPylyWbifg5A==", "license": "MIT" }, "node_modules/@primer/css": { "version": "22.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@primer/css/-/css-22.1.0.tgz", + "integrity": "sha512-Nwg9QaRiBeu0BU6h+Su0X07daihX1obiuqGRG8y+SexOnvWhN2J5n4OFAvGfQsit07Y7Q6gGoK+yVU5tb8CtDA==", "engines": { "node": ">=16.0.0" }, @@ -5417,13 +5888,16 @@ }, "node_modules/@primer/live-region-element": { "version": "0.8.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@primer/live-region-element/-/live-region-element-0.8.0.tgz", + "integrity": "sha512-DIp04IeZvZ+gwCUcBchIvRwJA2PikP/6hnFhcLKgwttcg5OYjBH9t0cZjLnv10Aq1jN0rAFFG+WPMd3bw4hXcA==", "dependencies": { "@lit-labs/ssr-dom-shim": "^1.2.1" } }, "node_modules/@primer/octicons-react": { "version": "19.21.0", + "resolved": "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-19.21.0.tgz", + "integrity": "sha512-KMWYYEIDKNIY0N3fMmNGPWJGHgoJF5NHkJllpOM3upDXuLtAe26Riogp1cfYdhp+sVjGZMt32DxcUhTX7ZhLOQ==", "license": "MIT", "engines": { "node": ">=8" @@ -5434,6 +5908,8 @@ }, "node_modules/@primer/primitives": { "version": "11.5.1", + "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-11.5.1.tgz", + "integrity": "sha512-NB9uYfJ01FVY6zp+33EoUbJ0paS3JrWY+PqdHPebTvyRtQgL3sX8//3jWqjt3/jL81UMEulJRM2A0hPj0/vFpQ==", "license": "MIT" }, "node_modules/@primer/view-components": { @@ -5463,25 +5939,31 @@ }, "node_modules/@probe.gl/env": { "version": "4.1.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.0.tgz", + "integrity": "sha512-5ac2Jm2K72VCs4eSMsM7ykVRrV47w32xOGMvcgqn8vQdEMF9PRXyBGYEV9YbqRKWNKpNKmQJVi4AHM/fkCxs9w==" }, "node_modules/@probe.gl/log": { "version": "4.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.0.tgz", + "integrity": "sha512-r4gRReNY6f+OZEMgfWEXrAE2qJEt8rX0HsDJQXUBMoc+5H47bdB7f/5HBHAmapK8UydwPKL9wCDoS22rJ0yq7Q==", "dependencies": { "@probe.gl/env": "4.1.0" } }, "node_modules/@probe.gl/stats": { "version": "4.1.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.0.tgz", + "integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==" }, "node_modules/@rails/actioncable": { "version": "7.0.6", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.0.6.tgz", + "integrity": "sha512-ybBsUrIsu5geM8BtqnpM7ZA9D8uzSz+e1B4JR57NaCmasHKWap6AX5DT7NHIbp21opVet1qqoVSdsoLDqXeB2A==" }, "node_modules/@rails/request.js": { "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@rails/request.js/-/request.js-0.0.13.tgz", + "integrity": "sha512-7MXmjFOPuaxpjG8brqKJG0EfIe9ak6R0wRnjCBtRuADNFbdlRxETdKx1T5NVU4Ato3iZOkEpeSUEuLboL3tCGA==", "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { @@ -5514,11 +5996,12 @@ }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -5574,9 +6057,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5591,9 +6071,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5608,9 +6085,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5625,9 +6099,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5642,9 +6113,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5659,9 +6127,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5676,9 +6141,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5693,9 +6155,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5710,9 +6169,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5727,9 +6183,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5744,9 +6197,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5761,9 +6211,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5778,9 +6225,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5873,13 +6317,15 @@ }, "node_modules/@rtsao/scc": { "version": "1.1.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true }, "node_modules/@rviscomi/capo.js": { "version": "2.1.0", - "dev": true, - "license": "Apache-2.0" + "resolved": "https://registry.npmjs.org/@rviscomi/capo.js/-/capo.js-2.1.0.tgz", + "integrity": "sha512-y6J+KJqsrY8AcDswLKkvd8KdpFindjS4Q9rSuK8CIpsQOepEjgRaMR4S8OtuLOQoVYLCROT3ffMQqRWrUMQdQA==", + "dev": true }, "node_modules/@schematics/angular": { "version": "22.0.4", @@ -5900,6 +6346,8 @@ }, "node_modules/@shikijs/types": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.1.0.tgz", + "integrity": "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -5911,10 +6359,14 @@ }, "node_modules/@shikijs/vscode-textmate": { "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, "node_modules/@sigstore/bundle": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", + "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", "license": "Apache-2.0", "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" @@ -5925,6 +6377,8 @@ }, "node_modules/@sigstore/core": { "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", + "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", "license": "Apache-2.0", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -5932,6 +6386,8 @@ }, "node_modules/@sigstore/protobuf-specs": { "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.1.tgz", + "integrity": "sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==", "license": "Apache-2.0", "engines": { "node": "^18.17.0 || >=20.5.0" @@ -5939,6 +6395,8 @@ }, "node_modules/@sigstore/sign": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.1.tgz", + "integrity": "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==", "license": "Apache-2.0", "dependencies": { "@gar/promise-retry": "^1.0.2", @@ -5954,6 +6412,8 @@ }, "node_modules/@sigstore/tuf": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.2.tgz", + "integrity": "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==", "license": "Apache-2.0", "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", @@ -5965,6 +6425,8 @@ }, "node_modules/@sigstore/verify": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", + "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", @@ -5977,17 +6439,22 @@ }, "node_modules/@standard-schema/spec": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, "node_modules/@stimulus-components/auto-submit": { "version": "6.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@stimulus-components/auto-submit/-/auto-submit-6.0.0.tgz", + "integrity": "sha512-GBwjjnNuDZey9kdJinTGyrkIMCNVnPrTQvqihC0pBiwieIMfZIp+TT7Giee0Z0DS+pX6WdJweIeo8xEYD/VZJg==", "peerDependencies": { "@hotwired/stimulus": "^3" } }, "node_modules/@stimulus-components/reveal": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@stimulus-components/reveal/-/reveal-5.0.0.tgz", + "integrity": "sha512-0ShvvDiG4qNLyFUTDrjGiR9MWR6D9EiAJRUSKxTPHA5Cc2Ci/A4Qj7cHDCoK2ZGHhpESfK0LsR9xtySCN6FTQw==", "license": "MIT", "peerDependencies": { "@hotwired/stimulus": "^3.0.0" @@ -5995,6 +6462,8 @@ }, "node_modules/@stylistic/eslint-plugin": { "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6014,8 +6483,9 @@ }, "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -6025,6 +6495,8 @@ }, "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -6036,6 +6508,8 @@ }, "node_modules/@tanstack/react-store": { "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", + "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==", "license": "MIT", "dependencies": { "@tanstack/store": "0.7.7", @@ -6052,6 +6526,8 @@ }, "node_modules/@tanstack/store": { "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.7.tgz", + "integrity": "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==", "license": "MIT", "funding": { "type": "github", @@ -6060,6 +6536,8 @@ }, "node_modules/@testing-library/dom": { "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", "dependencies": { @@ -6078,6 +6556,8 @@ }, "node_modules/@testing-library/dom/node_modules/aria-query": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6086,6 +6566,8 @@ }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { @@ -6104,11 +6586,15 @@ }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, "license": "MIT" }, "node_modules/@testing-library/react": { "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "license": "MIT", "dependencies": { @@ -6135,6 +6621,8 @@ }, "node_modules/@tiptap/core": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.24.0.tgz", + "integrity": "sha512-GTAsXAI32p4hEZgPzvUv2RPrObxamy9AFhmhG10fXSvN/cDUs8naEYVIqDV3Sh99jMwQEbTFKW1E1mcspsY6ow==", "license": "MIT", "funding": { "type": "github", @@ -6146,6 +6634,8 @@ }, "node_modules/@tiptap/extension-bold": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.24.0.tgz", + "integrity": "sha512-CujogYaynasklFKHADUseuvj8X2FnWktTCCo3Hl+nlyRvBTmm5TK2aqiamg3v2P4dBh3O6a70mo8BfRJPuiR1g==", "license": "MIT", "funding": { "type": "github", @@ -6157,6 +6647,8 @@ }, "node_modules/@tiptap/extension-bubble-menu": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.24.0.tgz", + "integrity": "sha512-jRXD+JPu9ayvq78g8hsCxx4q/qUFtrdfIYirRSf5YUseuuUbtfrq83AsGabcygpUTefjJkMQoXNITkh6294Ggw==", "license": "MIT", "optional": true, "dependencies": { @@ -6173,6 +6665,8 @@ }, "node_modules/@tiptap/extension-code": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.24.0.tgz", + "integrity": "sha512-MAQtrPRQ+HRmcGotWbksdIGeH1gqayFAdvi4lNGeFT7taHXP1o1XD7CQp7iYIKmg8IU4/MQ+RdetSfuC1A9edQ==", "license": "MIT", "funding": { "type": "github", @@ -6184,6 +6678,8 @@ }, "node_modules/@tiptap/extension-floating-menu": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.24.0.tgz", + "integrity": "sha512-7QEbf3mUzFAkejjQGX9f0L507oMtnOBRwHt2skUTR+9yXgudsN8zaDBSSRHLeMWGk9b7L293ZMA6zCRrZaHrfA==", "license": "MIT", "optional": true, "funding": { @@ -6198,6 +6694,8 @@ }, "node_modules/@tiptap/extension-horizontal-rule": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.24.0.tgz", + "integrity": "sha512-DFzWJTrb23x+qssLLs85vEyho8ItUGp3RY9XUsVTIAGZn5IsoUw8wMsvIBlH1ux4Ch7gLchtcD6kpTdMdrL9kw==", "license": "MIT", "funding": { "type": "github", @@ -6210,6 +6708,8 @@ }, "node_modules/@tiptap/extension-italic": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.24.0.tgz", + "integrity": "sha512-mf3cbNlbMPUNj3IyUkIke+o3ZpOUrtVeY5Yqs5IM/VhkUUh/PdIzqw74VuqEAJ0Z4oZ6nNDHeYLrl3Be1j99lQ==", "license": "MIT", "funding": { "type": "github", @@ -6221,6 +6721,8 @@ }, "node_modules/@tiptap/extension-paragraph": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.24.0.tgz", + "integrity": "sha512-wD06aB6hO7LgcrlhGiw7I64k2tus9kNoICX5R+UecBSB1DVJdzKvXoXL2kPNv4DqYvljHdkIeK/OpuOTQd6MJA==", "license": "MIT", "funding": { "type": "github", @@ -6232,6 +6734,8 @@ }, "node_modules/@tiptap/extension-strike": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.24.0.tgz", + "integrity": "sha512-sfN1iQs6Fdlorrfe8wipDkTPwu/Egx3s2fkY7TAWusTGFHwlovuRUGFKqCL9dI4N3u6uqUMpEuWmQNgv+aQGjQ==", "license": "MIT", "funding": { "type": "github", @@ -6243,6 +6747,8 @@ }, "node_modules/@tiptap/extension-text": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.24.0.tgz", + "integrity": "sha512-Im7keLPEihxm3+LyF+drYCoaOY5hlq35lvHAp/el6M8pJ/scts88HrYpdR1Yc4BtpZBIhfHSyWgPaupI4qwdeg==", "license": "MIT", "funding": { "type": "github", @@ -6254,6 +6760,8 @@ }, "node_modules/@tiptap/extension-underline": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.24.0.tgz", + "integrity": "sha512-D4W4X3UMq9dLVIOfPB9+UodQ4eAJ8yDcm8qFWAwq0a15YWH6bnwulCuIdV+U5dEG+yaRxN8haB9GrrID9jmrSA==", "license": "MIT", "funding": { "type": "github", @@ -6265,6 +6773,8 @@ }, "node_modules/@tiptap/extensions": { "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.26.1.tgz", + "integrity": "sha512-PmRaoe6bebTgz/ZQrjmzwZMST1d9js9ZTiKnUXeXl3Fm+V5U/c3TbbKDfqmL63qPQdjtShDMHi9tYuv+c77OFQ==", "license": "MIT", "funding": { "type": "github", @@ -6277,6 +6787,8 @@ }, "node_modules/@tiptap/pm": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.24.0.tgz", + "integrity": "sha512-QQP/78ryOZDN99gNBV7dgh69/8AYaOYQYFklq/iR+ZRFaaL3+qqHFvPVJapGkzPdymBgNJ34xjFM8n5pJ4QmMg==", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.3.0", @@ -6300,6 +6812,8 @@ }, "node_modules/@tiptap/react": { "version": "3.24.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.24.0.tgz", + "integrity": "sha512-KxnrlQbzOgA02EMsfuGGHtNhfkJQGqVlQttmQctI9DOl/F3gcaRqg+wNTBY1Fof8yDaZ8Z1LL1F0C05W0o3vUw==", "license": "MIT", "dependencies": { "@types/use-sync-external-store": "^0.0.6", @@ -6325,26 +6839,36 @@ }, "node_modules/@tsconfig/node10": { "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true, "license": "MIT" }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", "license": "MIT", "engines": { "node": "^16.14.0 || >=18.0.0" @@ -6352,6 +6876,8 @@ }, "node_modules/@tufjs/models": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", + "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", "license": "MIT", "dependencies": { "@tufjs/canonical-json": "2.0.0", @@ -6363,6 +6889,8 @@ }, "node_modules/@tufjs/models/node_modules/balanced-match": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -6370,6 +6898,8 @@ }, "node_modules/@tufjs/models/node_modules/brace-expansion": { "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -6380,6 +6910,8 @@ }, "node_modules/@tufjs/models/node_modules/minimatch": { "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -6393,11 +6925,15 @@ }, "node_modules/@types/aria-query": { "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -6407,6 +6943,8 @@ }, "node_modules/@types/codemirror": { "version": "5.60.17", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.17.tgz", + "integrity": "sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw==", "dev": true, "license": "MIT", "dependencies": { @@ -6415,36 +6953,48 @@ }, "node_modules/@types/css-tree": { "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@types/css-tree/-/css-tree-2.3.11.tgz", + "integrity": "sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg==", "dev": true, "license": "MIT" }, "node_modules/@types/deep-eql": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/dom-navigation": { "version": "1.0.7", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/dom-navigation/-/dom-navigation-1.0.7.tgz", + "integrity": "sha512-Di4W+i2faYquHUnyWUg3bBQp5pTNvjDDA7mIYfD/1WlLgan6sKkeVjGbdL78K0CuNEk5Pfc/c0rfelwkz10mnQ==", + "dev": true }, "node_modules/@types/dragula": { "version": "3.7.5", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/dragula/-/dragula-3.7.5.tgz", + "integrity": "sha512-jojr2JVJB8DawAKXApGnollMvVOMyiMKpchH8gLeoExx35Eq0BQ4WgAiAHoBoEn7h/9eDrIl0yz//cM6ALIJbg==", + "dev": true }, "node_modules/@types/esrecurse": { "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@types/flot": { "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/flot/-/flot-0.0.36.tgz", + "integrity": "sha512-xRo4MUIMnRPGXJCuQXAWvo+uKRmziRGHAy9LQHsLgbKanknpe5z3EThqVuYkVCC6ZWPZ/8pllBXnzQmGzFkJ/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6453,15 +7003,19 @@ }, "node_modules/@types/geojson": { "version": "7946.0.16", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==" }, "node_modules/@types/hammerjs": { "version": "2.0.46", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "dev": true }, "node_modules/@types/hast": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -6476,11 +7030,15 @@ }, "node_modules/@types/jquery": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-4.0.1.tgz", + "integrity": "sha512-9a59A/tycXgYuPABcp6/3spSShn0NT2UOM4EfHvMumjYi4lJWTsK5SZWjhx3yRm9IHGCeWXdV2YfNsrWrft/CA==", "dev": true, "license": "MIT" }, "node_modules/@types/jquery-migrate": { "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@types/jquery-migrate/-/jquery-migrate-3.3.3.tgz", + "integrity": "sha512-0CRPTHKGaK831+bxIbRerQpyLHwkNr/vSYZD5JAYpWw/TL2RUGiyWdwR8dAoGC4MpoyH5S+ato3+SyQDBmrZNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6489,20 +7047,26 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, "node_modules/@types/json5": { "version": "0.0.29", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true }, "node_modules/@types/lodash": { "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", "dev": true, "license": "MIT" }, "node_modules/@types/lodash-es": { "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6511,8 +7075,9 @@ }, "node_modules/@types/mousetrap": { "version": "1.6.15", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.6.15.tgz", + "integrity": "sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw==", + "dev": true }, "node_modules/@types/node": { "version": "25.9.4", @@ -6526,16 +7091,21 @@ }, "node_modules/@types/pako": { "version": "2.0.4", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "dev": true }, "node_modules/@types/rails__request.js": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@types/rails__request.js/-/rails__request.js-0.0.1.tgz", + "integrity": "sha512-s9KX0falKcyDO2SjER/3HSfcJ8MAm+V05hxW1jJd/Rt+X7QeSSf9g6d2EZ6/uLJiDnXAqc+WPlxT1oYswV2T9A==", "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", "dependencies": { @@ -6544,44 +7114,56 @@ }, "node_modules/@types/react-dom": { "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, - "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" } }, "node_modules/@types/resize-observer-browser": { "version": "0.1.11", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/resize-observer-browser/-/resize-observer-browser-0.1.11.tgz", + "integrity": "sha512-cNw5iH8JkMkb3QkCoe7DaZiawbDQEUX8t7iuQaRTyLOyQCR2h+ibBD4GJt7p5yhUHrlOeL7ZtbxNHeipqNsBzQ==", + "dev": true }, "node_modules/@types/tern": { "version": "0.23.4", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.4.tgz", + "integrity": "sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==", "dev": true, - "license": "MIT", "dependencies": { "@types/estree": "*" } }, "node_modules/@types/trusted-types": { "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, "node_modules/@types/unist": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, "node_modules/@types/urijs": { "version": "1.19.26", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/urijs/-/urijs-1.19.26.tgz", + "integrity": "sha512-wkXrVzX5yoqLnndOwFsieJA7oKM8cNkOKJtf/3vVGSUFkWDKZvFHpIl9Pvqb/T9UsawBBFMTTD8xu7sK5MWuvg==", + "dev": true }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6602,21 +7184,24 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.61.1", + "@typescript-eslint/parser": "8.61.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -6640,6 +7225,8 @@ }, "node_modules/@typescript-eslint/project-service": { "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", "dev": true, "license": "MIT", "dependencies": { @@ -6660,6 +7247,8 @@ }, "node_modules/@typescript-eslint/scope-manager": { "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", "dev": true, "license": "MIT", "dependencies": { @@ -6676,6 +7265,8 @@ }, "node_modules/@typescript-eslint/tsconfig-utils": { "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", "dev": true, "license": "MIT", "engines": { @@ -6691,6 +7282,8 @@ }, "node_modules/@typescript-eslint/type-utils": { "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", "dev": true, "license": "MIT", "dependencies": { @@ -6714,6 +7307,8 @@ }, "node_modules/@typescript-eslint/types": { "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", "dev": true, "license": "MIT", "engines": { @@ -6726,6 +7321,8 @@ }, "node_modules/@typescript-eslint/typescript-estree": { "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", "dev": true, "license": "MIT", "dependencies": { @@ -6752,6 +7349,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { @@ -6760,6 +7359,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -6771,6 +7372,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -6785,6 +7388,8 @@ }, "node_modules/@typescript-eslint/utils": { "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", "dev": true, "license": "MIT", "dependencies": { @@ -6807,6 +7412,8 @@ }, "node_modules/@typescript-eslint/visitor-keys": { "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", "dev": true, "license": "MIT", "dependencies": { @@ -6823,6 +7430,8 @@ }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -6834,6 +7443,8 @@ }, "node_modules/@uirouter/angular": { "version": "21.0.0", + "resolved": "https://registry.npmjs.org/@uirouter/angular/-/angular-21.0.0.tgz", + "integrity": "sha512-KUvyNb5lJ293Lzym+UwfgzX7U/5LpOp4nmp8EAzwAOBu/K8A+wZeVTS1NYQAf/ziO+N3nqI0I8+GnXYOzSOtaA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -6850,14 +7461,16 @@ }, "node_modules/@uirouter/core": { "version": "6.1.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@uirouter/core/-/core-6.1.2.tgz", + "integrity": "sha512-pYcg+cxVd9E9poC7AJf7ZrlQQrwAV6KVkiPvlbLJX5km+pBWrUOGQhdd87oIGc7/5iVQ7qSiAdl9QD+l55yegg==", "engines": { "node": ">=4.0.0" } }, "node_modules/@uirouter/rx": { "version": "1.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@uirouter/rx/-/rx-1.0.0.tgz", + "integrity": "sha512-dqPmLFC+qqF6RIdJVKktXSON6WILy2oyLhADDk74F3GAUZ/VvOu3QSPLDtZEP3LMSo6vkGQvwcUdjgNVWL3YJA==", "peerDependencies": { "@uirouter/core": ">=6.0.1", "rxjs": "^6.5.3 || ^7.4.0" @@ -6865,6 +7478,8 @@ }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", + "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", "dev": true, "license": "MIT", "engines": { @@ -6876,6 +7491,8 @@ }, "node_modules/@vitest/browser": { "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.9.tgz", + "integrity": "sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg==", "dev": true, "license": "MIT", "dependencies": { @@ -6897,6 +7514,8 @@ }, "node_modules/@vitest/browser-playwright": { "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.9.tgz", + "integrity": "sha512-Bq1rOGf9waevzG3EOkO/dene6bvKTUsZMVg8S1i+WH3JcMjuXEjiahP9rAqZRELUqjBySOJsvvSWqK/B3wjKQw==", "dev": true, "license": "MIT", "dependencies": { @@ -6919,6 +7538,8 @@ }, "node_modules/@vitest/coverage-v8": { "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", "dependencies": { @@ -6948,6 +7569,8 @@ }, "node_modules/@vitest/eslint-plugin": { "version": "1.6.20", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.20.tgz", + "integrity": "sha512-xRwWHFG0Utp6hXtbGiWk4VdKXCGdExD8kbWrrmFEiG5dk8anOJ+vbWbeOa8EbkocKQRTsx7JAWETccZiBgFp/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6977,6 +7600,8 @@ }, "node_modules/@vitest/expect": { "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { @@ -6993,6 +7618,8 @@ }, "node_modules/@vitest/mocker": { "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { @@ -7018,6 +7645,8 @@ }, "node_modules/@vitest/pretty-format": { "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -7029,6 +7658,8 @@ }, "node_modules/@vitest/runner": { "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { @@ -7041,6 +7672,8 @@ }, "node_modules/@vitest/snapshot": { "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { @@ -7055,6 +7688,8 @@ }, "node_modules/@vitest/spy": { "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -7063,6 +7698,8 @@ }, "node_modules/@vitest/utils": { "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { @@ -7076,17 +7713,22 @@ }, "node_modules/@vitest/utils/node_modules/convert-source-map": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, "node_modules/@vscode/l10n": { "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", "dev": true, "license": "MIT" }, "node_modules/@w11k/ngx-componentdestroyed": { "version": "5.0.2", - "license": "Apache-2.0", + "resolved": "https://registry.npmjs.org/@w11k/ngx-componentdestroyed/-/ngx-componentdestroyed-5.0.2.tgz", + "integrity": "sha512-njpK7h6hSpF8LQp2bayb47T7rTxOwx7745TOiUP88y8YAT2JOY5OeJpYqlK2WrQQBeD7CT+DxozD6yNBN283dA==", "dependencies": { "rxjs": ">=6.5.0" }, @@ -7097,7 +7739,8 @@ }, "node_modules/@xeokit/xeokit-bim-viewer": { "version": "2.7.1", - "license": "AGPL-3.0 license", + "resolved": "https://registry.npmjs.org/@xeokit/xeokit-bim-viewer/-/xeokit-bim-viewer-2.7.1.tgz", + "integrity": "sha512-qvlYjYt8EpRmrQqPLQ3/lpmZzpjS8Pfr0zwpHm5mO3fIC7m6Vo7a3nQRHu2JlsSfB88/2ujl59MAj63B/JTFUw==", "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", "@xeokit/xeokit-sdk": "^2.6.78", @@ -7106,7 +7749,8 @@ }, "node_modules/@xeokit/xeokit-sdk": { "version": "2.6.90", - "license": "AGPL-3.0", + "resolved": "https://registry.npmjs.org/@xeokit/xeokit-sdk/-/xeokit-sdk-2.6.90.tgz", + "integrity": "sha512-wkZmsWr0fhefrKjlByUs+6j2mizbUQiQJ4nihYwcQrkuECy52e7ntBdpfWVVOXd1eIcV5R2a62AhqR/SjOP0Ww==", "dependencies": { "@loaders.gl/core": "^4.3.3", "@loaders.gl/gltf": "^4.3.3", @@ -7116,10 +7760,13 @@ }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", - "license": "BSD-2-Clause" + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==" }, "node_modules/abbrev": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -7127,6 +7774,8 @@ }, "node_modules/acorn": { "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -7138,6 +7787,8 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7146,14 +7797,17 @@ }, "node_modules/acorn-walk": { "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.4.0" } }, "node_modules/agent-base": { "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { "node": ">= 14" @@ -7161,6 +7815,8 @@ }, "node_modules/ajv": { "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -7175,7 +7831,8 @@ }, "node_modules/ajv-formats": { "version": "2.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dependencies": { "ajv": "^8.0.0" }, @@ -7190,6 +7847,8 @@ }, "node_modules/algoliasearch": { "version": "5.52.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.52.0.tgz", + "integrity": "sha512-0ZzY9mjqV7gop/AH8pIBiAS8giXP7WcSiUfoFYIzYAK9QC5c37E4SIVtJVBMwlURc0/uNt2o4RcNRvdHa4CJ5w==", "license": "MIT", "dependencies": { "@algolia/abtesting": "1.18.0", @@ -7213,6 +7872,8 @@ }, "node_modules/angular-eslint": { "version": "22.0.0", + "resolved": "https://registry.npmjs.org/angular-eslint/-/angular-eslint-22.0.0.tgz", + "integrity": "sha512-6tHLndzM6rU+2iuICakJS/hD1scK5sWLkcD7828zStT1ViA9zX8z9g/V1IlBiKEdZeMsl+m7K2DlNc34AkYyoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7235,18 +7896,22 @@ }, "node_modules/animation-frame-polyfill": { "version": "1.0.2", - "license": "MIT" + "resolved": "https://registry.npmjs.org/animation-frame-polyfill/-/animation-frame-polyfill-1.0.2.tgz", + "integrity": "sha512-PvO5poSMoHhaoNNgHPo+oqs/0L9UqjsUbqv0iOXVqLh6HX85fsOVQTUrzSBvjdZz7hydARlgLELyzJJKIrPJAQ==" }, "node_modules/ansi-regex": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -7260,28 +7925,32 @@ }, "node_modules/arg": { "version": "4.1.3", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true }, "node_modules/argparse": { "version": "1.0.10", - "license": "MIT", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dependencies": { "sprintf-js": "~1.0.2" } }, "node_modules/aria-query": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">= 0.4" } }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" @@ -7295,10 +7964,13 @@ }, "node_modules/array-from": { "version": "2.1.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", + "integrity": "sha512-GQTc6Uupx1FCavi5mPzBvVT7nEOeWMmUA9P95wpfpW1XwMSKs+KaymD5C2Up7KAUKg/mYwbsUYzdZWcoajlNZg==" }, "node_modules/array-includes": { "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7320,8 +7992,9 @@ }, "node_modules/array.prototype.findlast": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -7339,6 +8012,8 @@ }, "node_modules/array.prototype.findlastindex": { "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7359,6 +8034,8 @@ }, "node_modules/array.prototype.flat": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { @@ -7376,8 +8053,9 @@ }, "node_modules/array.prototype.flatmap": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -7393,8 +8071,9 @@ }, "node_modules/array.prototype.tosorted": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -7408,8 +8087,9 @@ }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, - "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", @@ -7428,6 +8108,8 @@ }, "node_modules/assertion-error": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -7436,11 +8118,14 @@ }, "node_modules/ast-types-flow": { "version": "0.0.8", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true }, "node_modules/ast-v8-to-istanbul": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -7451,20 +8136,26 @@ }, "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, "node_modules/async": { "version": "3.2.6", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true }, "node_modules/atoa": { "version": "1.0.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/atoa/-/atoa-1.0.0.tgz", + "integrity": "sha512-VVE1H6cc4ai+ZXo/CRWoJiHXrA1qfA31DPnx6D20+kSI547hQN5Greh51LQ1baMRMfxO5K5M4ImMtZbZt2DODQ==" }, "node_modules/autoprefixer": { "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", "funding": [ { "type": "opencollective", @@ -7499,8 +8190,9 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, - "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" }, @@ -7513,34 +8205,40 @@ }, "node_modules/axe-core": { "version": "4.10.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.0.tgz", + "integrity": "sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==", "dev": true, - "license": "MPL-2.0", "engines": { "node": ">=4" } }, "node_modules/axobject-query": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">= 0.4" } }, "node_modules/balanced-match": { "version": "1.0.2", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/base64-arraybuffer": { "version": "1.0.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", "engines": { "node": ">= 0.6.0" } }, "node_modules/base64-js": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", @@ -7559,6 +8257,8 @@ }, "node_modules/base64url": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -7578,13 +8278,16 @@ }, "node_modules/basic-auth": { "version": "1.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-1.1.0.tgz", + "integrity": "sha512-CtGuTyWf3ig+sgRyC7uP6DM3N+5ur/p8L+FPfsd+BbIfIs74TFfCajZTHnCw6K5dqM0bZEbRIqRy1fAdiUJhTA==", "engines": { "node": ">= 0.6" } }, "node_modules/beasties": { "version": "0.4.2", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.2.tgz", + "integrity": "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -7604,6 +8307,8 @@ }, "node_modules/bidi-js": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, "license": "MIT", "dependencies": { @@ -7612,24 +8317,30 @@ }, "node_modules/bignumber.js": { "version": "9.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.1.tgz", + "integrity": "sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig==", "engines": { "node": "*" } }, "node_modules/bind-event-listener": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz", + "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==", "license": "MIT" }, "node_modules/boolbase": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true, "license": "ISC" }, "node_modules/brace-expansion": { "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7637,8 +8348,9 @@ }, "node_modules/braces": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, - "license": "MIT", "dependencies": { "fill-range": "^7.1.1" }, @@ -7681,8 +8393,9 @@ }, "node_modules/btoa": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", "dev": true, - "license": "(MIT OR Apache-2.0)", "bin": { "btoa": "bin/btoa.js" }, @@ -7692,6 +8405,8 @@ }, "node_modules/buffer": { "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -7714,22 +8429,27 @@ }, "node_modules/buffer-from": { "version": "1.1.2", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true }, "node_modules/byte-base64": { "version": "1.1.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/byte-base64/-/byte-base64-1.1.0.tgz", + "integrity": "sha512-56cXelkJrVMdCY9V/3RfDxTh4VfMFCQ5km7B7GkIGfo4bcPL9aACyJLB0Ms3Ezu5rsHmLB2suis96z4fLM03DA==" }, "node_modules/bytes": { "version": "3.1.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "engines": { "node": ">= 0.8" } }, "node_modules/cacache": { "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", "license": "ISC", "dependencies": { "@npmcli/fs": "^5.0.0", @@ -7749,6 +8469,8 @@ }, "node_modules/cacache/node_modules/lru-cache": { "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -7756,8 +8478,9 @@ }, "node_modules/call-bind": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, - "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", @@ -7773,6 +8496,8 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7784,6 +8509,8 @@ }, "node_modules/call-bound": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7798,19 +8525,23 @@ }, "node_modules/call-me-maybe": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", "license": "MIT" }, "node_modules/camelize": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/caniuse-lite": { - "version": "1.0.30001800", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "funding": [ { "type": "opencollective", @@ -7829,6 +8560,8 @@ }, "node_modules/chai": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -7837,6 +8570,8 @@ }, "node_modules/chalk": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -7851,11 +8586,14 @@ }, "node_modules/chardet": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", "license": "MIT" }, "node_modules/chart.js": { "version": "4.5.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -7865,6 +8603,8 @@ }, "node_modules/chartjs-adapter-luxon": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/chartjs-adapter-luxon/-/chartjs-adapter-luxon-1.3.1.tgz", + "integrity": "sha512-yxHov3X8y+reIibl1o+j18xzrcdddCLqsXhriV2+aQ4hCR66IYFchlRXUvrJVoxglJ380pgytU7YWtoqdIgqhg==", "license": "MIT", "peerDependencies": { "chart.js": ">=3.0.0", @@ -7873,13 +8613,16 @@ }, "node_modules/chartjs-plugin-datalabels": { "version": "2.2.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", + "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", "peerDependencies": { "chart.js": ">=3.0.0" } }, "node_modules/chownr": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -7887,6 +8630,8 @@ }, "node_modules/ci-info": { "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "dev": true, "funding": [ { @@ -7901,6 +8646,8 @@ }, "node_modules/cli-cursor": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "license": "MIT", "dependencies": { "restore-cursor": "^5.0.0" @@ -7914,6 +8661,8 @@ }, "node_modules/cli-spinners": { "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", "license": "MIT", "engines": { "node": ">=18.20" @@ -7924,6 +8673,8 @@ }, "node_modules/cli-truncate": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "license": "MIT", "dependencies": { "slice-ansi": "^8.0.0", @@ -7938,6 +8689,8 @@ }, "node_modules/cli-truncate/node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -7948,6 +8701,8 @@ }, "node_modules/cli-truncate/node_modules/ansi-styles": { "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -7958,6 +8713,8 @@ }, "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.3.1" @@ -7971,6 +8728,8 @@ }, "node_modules/cli-truncate/node_modules/slice-ansi": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.3", @@ -7985,6 +8744,8 @@ }, "node_modules/cli-truncate/node_modules/string-width": { "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -7999,6 +8760,8 @@ }, "node_modules/cli-truncate/node_modules/strip-ansi": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -8012,6 +8775,8 @@ }, "node_modules/cli-width": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "license": "ISC", "engines": { "node": ">= 12" @@ -8019,8 +8784,9 @@ }, "node_modules/cliui": { "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, - "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -8029,6 +8795,8 @@ }, "node_modules/clsx": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { "node": ">=6" @@ -8036,10 +8804,14 @@ }, "node_modules/codemirror": { "version": "5.65.21", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.21.tgz", + "integrity": "sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ==", "license": "MIT" }, "node_modules/color": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", "license": "MIT", "dependencies": { "color-convert": "^2.0.1", @@ -8051,7 +8823,8 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dependencies": { "color-name": "~1.1.4" }, @@ -8061,10 +8834,13 @@ }, "node_modules/color-name": { "version": "1.1.4", - "license": "MIT" + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/color-string": { "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", "license": "MIT", "dependencies": { "color-name": "^1.0.0", @@ -8073,19 +8849,22 @@ }, "node_modules/concat-map": { "version": "0.0.1", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true }, "node_modules/content-type": { "version": "1.0.5", - "license": "MIT", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "engines": { "node": ">= 0.6" } }, "node_modules/contra": { "version": "1.9.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/contra/-/contra-1.9.4.tgz", + "integrity": "sha512-N9ArHAqwR/lhPq4OdIAwH4e1btn6EIZMAz4TazjnzCiVECcWUPTma+dRAM38ERImEJBh8NiCCpjoQruSZ+agYg==", "dependencies": { "atoa": "1.0.0", "ticky": "1.0.1" @@ -8093,18 +8872,21 @@ }, "node_modules/convert-source-map": { "version": "1.9.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cookie": { "version": "0.7.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "engines": { "node": ">= 0.6" } }, "node_modules/copy-text-to-clipboard": { "version": "3.2.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz", + "integrity": "sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A==", "engines": { "node": ">=12" }, @@ -8114,8 +8896,9 @@ }, "node_modules/core-js": { "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", "hasInstallScript": true, - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -8123,6 +8906,8 @@ }, "node_modules/core-js-pure": { "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.47.0.tgz", + "integrity": "sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -8132,6 +8917,8 @@ }, "node_modules/cors": { "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -8147,26 +8934,31 @@ }, "node_modules/corser": { "version": "2.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", "engines": { "node": ">= 0.4.0" } }, "node_modules/create-point-cb": { "version": "1.2.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/create-point-cb/-/create-point-cb-1.2.0.tgz", + "integrity": "sha512-r4l6IO/YGI7hIZRMLggOzwM6XO80+Fdcv4hx1fXCEdU+hKd7zZki6i+cbYfK9OliMwMYx1wPfQLU/snvS+Dygw==", "dependencies": { "type-func": "^1.0.1" } }, "node_modules/create-require": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", - "license": "MIT", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -8178,13 +8970,16 @@ }, "node_modules/crossvent": { "version": "1.5.5", - "license": "MIT", + "resolved": "https://registry.npmjs.org/crossvent/-/crossvent-1.5.5.tgz", + "integrity": "sha512-MY4xhBYEnVi+pmTpHCOCsCLYczc0PVtGdPBz6NXNXxikLaUZo4HdAeUb1UqAo3t3yXAloSelTmfxJ+/oUqkW5w==", "dependencies": { "custom-event": "^1.0.0" } }, "node_modules/css-color-keywords": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", "license": "ISC", "engines": { "node": ">=4" @@ -8192,13 +8987,16 @@ }, "node_modules/css-line-break": { "version": "2.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", "dependencies": { "utrie": "^1.0.2" } }, "node_modules/css-select": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -8214,6 +9012,8 @@ }, "node_modules/css-to-react-native": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", "license": "MIT", "dependencies": { "camelize": "^1.0.0", @@ -8223,6 +9023,8 @@ }, "node_modules/css-tree": { "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { @@ -8235,6 +9037,8 @@ }, "node_modules/css-what": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -8246,31 +9050,40 @@ }, "node_modules/css.escape": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true, "license": "MIT" }, "node_modules/csstype": { "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/custom-event": { "version": "1.0.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==" }, "node_modules/d": { "version": "0.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/d/-/d-0.1.1.tgz", + "integrity": "sha512-0SdM9V9pd/OXJHoWmTfNPTAeD+lw6ZqHg+isPyBFuJsZLSE0Ygg1cYZ/0l6DrKQXMOqGOu1oWupMoOfoRfMZrQ==", "dependencies": { "es5-ext": "~0.10.2" } }, "node_modules/damerau-levenshtein": { "version": "1.0.8", - "dev": true, - "license": "BSD-2-Clause" + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true }, "node_modules/data-urls": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, "license": "MIT", "dependencies": { @@ -8283,8 +9096,9 @@ }, "node_modules/data-view-buffer": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -8299,8 +9113,9 @@ }, "node_modules/data-view-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -8315,8 +9130,9 @@ }, "node_modules/data-view-byte-offset": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -8331,6 +9147,8 @@ }, "node_modules/debug": { "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8346,18 +9164,23 @@ }, "node_modules/decimal.js": { "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, "license": "MIT" }, "node_modules/deep-is": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/define-data-property": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, - "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -8372,8 +9195,9 @@ }, "node_modules/define-properties": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, - "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -8388,23 +9212,26 @@ }, "node_modules/depd": { "version": "2.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "engines": { "node": ">= 0.8" } }, "node_modules/dequal": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/detect-libc": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "Apache-2.0", "optional": true, "engines": { "node": ">=8" @@ -8412,10 +9239,14 @@ }, "node_modules/detect-node-es": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, "node_modules/diff": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -8424,12 +9255,15 @@ }, "node_modules/dom-accessibility-api": { "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, "license": "MIT" }, "node_modules/dom-autoscroller": { "version": "2.3.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/dom-autoscroller/-/dom-autoscroller-2.3.4.tgz", + "integrity": "sha512-HcAdt/2Dq9x4CG6LWXc2x9Iq0MJPAu8fuzHncclq7byufqYEYVtx9sZ/dyzR+gdj4qwEC9p27Lw1G2HRRYX6jQ==", "dependencies": { "animation-frame-polyfill": "^1.0.0", "create-point-cb": "^1.0.0", @@ -8441,17 +9275,21 @@ }, "node_modules/dom-mousemove-dispatcher": { "version": "1.0.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/dom-mousemove-dispatcher/-/dom-mousemove-dispatcher-1.0.1.tgz", + "integrity": "sha512-NMdqqMbgW8kqOdmod2hkS+9hD/v7h4XoSvwU9qqe+wAA/O+ba0jhpbfW0Kb/fCyR0RX9jf4dwfQrl04LQX4FzQ==" }, "node_modules/dom-plane": { "version": "1.0.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/dom-plane/-/dom-plane-1.0.2.tgz", + "integrity": "sha512-/tR67G6ZGSciXoZLsD706yLxEXvX3mG/OWE8YNYj3A1yU/RAimtPXzklVTu5Y5xoeMoloA/Y+MaNjQm9apgAww==", "dependencies": { "create-point-cb": "^1.0.0" } }, "node_modules/dom-serializer": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "dev": true, "license": "MIT", "dependencies": { @@ -8465,7 +9303,8 @@ }, "node_modules/dom-set": { "version": "1.1.1", - "license": "WTFPL", + "resolved": "https://registry.npmjs.org/dom-set/-/dom-set-1.1.1.tgz", + "integrity": "sha512-sUi2aSvRsK3Ixx++gwX9cnaWk9ZxGVFry8+HnTRVmDimybU5PaiI4wX0o00mVtjFKlQNZLmtGoPTLorYbN0+Rw==", "dependencies": { "array-from": "^2.1.1", "is-array": "^1.0.1", @@ -8474,6 +9313,8 @@ }, "node_modules/domelementtype": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "dev": true, "funding": [ { @@ -8485,6 +9326,8 @@ }, "node_modules/domhandler": { "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -8499,6 +9342,8 @@ }, "node_modules/domutils": { "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -8512,11 +9357,13 @@ }, "node_modules/draco3d": { "version": "1.5.7", - "license": "Apache-2.0" + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==" }, "node_modules/dragula": { "version": "3.7.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/dragula/-/dragula-3.7.3.tgz", + "integrity": "sha512-/rRg4zRhcpf81TyDhaHLtXt6sEywdfpv1cRUMeFFy7DuypH2U0WUL0GTdyAQvXegviT4PJK4KuMmOaIDpICseQ==", "dependencies": { "contra": "1.9.4", "crossvent": "1.5.5" @@ -8524,6 +9371,8 @@ }, "node_modules/drange": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", "license": "MIT", "engines": { "node": ">=4" @@ -8531,7 +9380,8 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -8543,17 +9393,20 @@ }, "node_modules/duplexer": { "version": "0.1.2", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true }, "node_modules/ee-first": { "version": "1.1.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/ejs": { "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, - "license": "Apache-2.0", "dependencies": { "jake": "^10.8.5" }, @@ -8565,22 +9418,27 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.382", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.382.tgz", - "integrity": "sha512-8ETaWbV6SZOrno+G93Ffd9ENsMtetqdnqj4nlfxFW90Sm5GgnuV28Kf62hqQVD6VUgzm7qFQKsTsAPmeUiU3Ug==", + "version": "1.5.381", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", + "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==", "license": "ISC" }, "node_modules/emoji-mart": { "version": "5.6.0", + "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", + "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==", "license": "MIT" }, "node_modules/emoji-regex": { "version": "9.2.2", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true }, "node_modules/encodeurl": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -8588,6 +9446,8 @@ }, "node_modules/entities": { "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -8599,6 +9459,8 @@ }, "node_modules/env-paths": { "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "license": "MIT", "engines": { "node": ">=6" @@ -8606,6 +9468,8 @@ }, "node_modules/environment": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "license": "MIT", "engines": { "node": ">=18" @@ -8616,6 +9480,8 @@ }, "node_modules/es-abstract": { "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "dev": true, "license": "MIT", "dependencies": { @@ -8683,27 +9549,32 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "engines": { "node": ">= 0.4" } }, "node_modules/es-errors": { "version": "1.3.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "engines": { "node": ">= 0.4" } }, "node_modules/es-html-parser": { "version": "0.3.1", + "resolved": "https://registry.npmjs.org/es-html-parser/-/es-html-parser-0.3.1.tgz", + "integrity": "sha512-YTEasG4xt7FEN4b6qJIPbFo/fzQ5kjRMEQ33QMqSXTvfXqAbC2rHxo32x2/1Rhq7Mlu6wI3MIpM5Kf2VHPXrUQ==", "dev": true, "license": "MIT" }, "node_modules/es-iterator-helpers": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -8728,11 +9599,14 @@ }, "node_modules/es-module-lexer": { "version": "2.1.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true }, "node_modules/es-object-atoms": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -8743,8 +9617,9 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, - "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -8757,6 +9632,8 @@ }, "node_modules/es-shim-unscopables": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { @@ -8768,8 +9645,9 @@ }, "node_modules/es-to-primitive": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dev": true, - "license": "MIT", "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", @@ -8784,16 +9662,14 @@ }, "node_modules/es-toolkit": { "version": "1.46.1", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==" }, "node_modules/es5-ext": { "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", "hasInstallScript": true, - "license": "ISC", "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", @@ -8806,7 +9682,8 @@ }, "node_modules/es6-iterator": { "version": "2.0.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", "dependencies": { "d": "1", "es5-ext": "^0.10.35", @@ -8815,7 +9692,8 @@ }, "node_modules/es6-iterator/node_modules/d": { "version": "1.0.2", - "license": "ISC", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" @@ -8826,11 +9704,14 @@ }, "node_modules/es6-slide-up-down": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es6-slide-up-down/-/es6-slide-up-down-1.0.0.tgz", + "integrity": "sha512-s86t2F+GjPRxiSodC59JRZZaP07Ht03EDU2RDrRCXYk9o3CfTCNjtwqrpoV1BjbCnzXcBoDGj1aZhzZkHzMpWQ==", "license": "MIT" }, "node_modules/es6-symbol": { "version": "3.1.4", - "license": "ISC", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" @@ -8841,7 +9722,8 @@ }, "node_modules/es6-symbol/node_modules/d": { "version": "1.0.2", - "license": "ISC", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" @@ -8852,7 +9734,8 @@ }, "node_modules/es6-weak-map": { "version": "0.1.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-0.1.4.tgz", + "integrity": "sha512-P+N5Cd2TXeb7G59euFiM7snORspgbInS29Nbf3KNO2JQp/DyhvMCDWd58nsVAXwYJ6W3Bx7qDdy6QQ3PCJ7jKQ==", "dependencies": { "d": "~0.1.1", "es5-ext": "~0.10.6", @@ -8862,7 +9745,8 @@ }, "node_modules/es6-weak-map/node_modules/es6-iterator": { "version": "0.1.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-0.1.3.tgz", + "integrity": "sha512-6TOmbFM6OPWkTe+bQ3ZuUkvqcWUjAnYjKUCLdbvRsAUz2Pr+fYIibwNXNkLNtIK9PPFbNMZZddaRNkyJhlGJhA==", "dependencies": { "d": "~0.1.1", "es5-ext": "~0.10.5", @@ -8871,7 +9755,8 @@ }, "node_modules/es6-weak-map/node_modules/es6-symbol": { "version": "2.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-2.0.1.tgz", + "integrity": "sha512-wjobO4zO8726HVU7mI2OA/B6QszqwHJuKab7gKHVx+uRfVVYGcWJkCIFxV2Madqb9/RUSrhJ/r6hPfG7FsWtow==", "dependencies": { "d": "~0.1.1", "es5-ext": "~0.10.5" @@ -8879,6 +9764,8 @@ }, "node_modules/esbuild": { "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -8919,17 +9806,21 @@ }, "node_modules/escalade": { "version": "3.2.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "engines": { "node": ">=6" } }, "node_modules/escape-html": { "version": "1.0.3", - "license": "MIT" + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "node_modules/escape-string-regexp": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -8940,9 +9831,9 @@ } }, "node_modules/eslint": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", - "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -9000,8 +9891,9 @@ }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, - "license": "MIT", "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", @@ -9010,14 +9902,17 @@ }, "node_modules/eslint-import-resolver-node/node_modules/debug": { "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, - "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/eslint-module-utils": { "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, "license": "MIT", "dependencies": { @@ -9034,6 +9929,8 @@ }, "node_modules/eslint-module-utils/node_modules/debug": { "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9042,6 +9939,8 @@ }, "node_modules/eslint-plugin-import": { "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", "dependencies": { @@ -9074,16 +9973,18 @@ }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, - "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/eslint-plugin-import/node_modules/doctrine": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -9093,8 +9994,9 @@ }, "node_modules/eslint-plugin-import/node_modules/json5": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, - "license": "MIT", "dependencies": { "minimist": "^1.2.0" }, @@ -9104,16 +10006,18 @@ }, "node_modules/eslint-plugin-import/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/eslint-plugin-import/node_modules/tsconfig-paths": { "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, - "license": "MIT", "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", @@ -9123,8 +10027,9 @@ }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, - "license": "MIT", "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -9151,6 +10056,8 @@ }, "node_modules/eslint-plugin-react": { "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { @@ -9182,6 +10089,8 @@ }, "node_modules/eslint-plugin-react-hooks": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { @@ -9200,8 +10109,9 @@ }, "node_modules/eslint-plugin-react/node_modules/doctrine": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -9211,8 +10121,9 @@ }, "node_modules/eslint-plugin-react/node_modules/resolve": { "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, - "license": "MIT", "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -9227,14 +10138,17 @@ }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/eslint-scope": { "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -9252,8 +10166,9 @@ }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -9263,6 +10178,8 @@ }, "node_modules/eslint/node_modules/@eslint/core": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -9274,6 +10191,8 @@ }, "node_modules/eslint/node_modules/@eslint/plugin-kit": { "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -9286,6 +10205,8 @@ }, "node_modules/eslint/node_modules/ajv": { "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -9301,6 +10222,8 @@ }, "node_modules/eslint/node_modules/balanced-match": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { @@ -9309,6 +10232,8 @@ }, "node_modules/eslint/node_modules/brace-expansion": { "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -9320,6 +10245,8 @@ }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -9331,6 +10258,8 @@ }, "node_modules/eslint/node_modules/espree": { "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -9347,6 +10276,8 @@ }, "node_modules/eslint/node_modules/glob-parent": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -9358,11 +10289,15 @@ }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/eslint/node_modules/minimatch": { "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -9377,7 +10312,8 @@ }, "node_modules/esniff": { "version": "2.0.1", - "license": "ISC", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", @@ -9390,7 +10326,8 @@ }, "node_modules/esniff/node_modules/d": { "version": "1.0.2", - "license": "ISC", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" @@ -9401,8 +10338,9 @@ }, "node_modules/espree": { "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -9417,8 +10355,9 @@ }, "node_modules/espree/node_modules/eslint-visitor-keys": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -9428,6 +10367,8 @@ }, "node_modules/esquery": { "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -9439,8 +10380,9 @@ }, "node_modules/esrecurse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -9450,14 +10392,17 @@ }, "node_modules/estraverse": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, "node_modules/estree-walker": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -9466,22 +10411,25 @@ }, "node_modules/esutils": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } }, "node_modules/etag": { "version": "1.8.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "engines": { "node": ">= 0.6" } }, "node_modules/event-emitter": { "version": "0.3.5", - "license": "MIT", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", "dependencies": { "d": "1", "es5-ext": "~0.10.14" @@ -9489,7 +10437,8 @@ }, "node_modules/event-emitter/node_modules/d": { "version": "1.0.2", - "license": "ISC", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" @@ -9500,10 +10449,13 @@ }, "node_modules/eventemitter3": { "version": "4.0.7", - "license": "MIT" + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" }, "node_modules/eventsource": { "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -9514,6 +10466,8 @@ }, "node_modules/eventsource-parser": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -9521,6 +10475,8 @@ }, "node_modules/expect-type": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -9529,10 +10485,14 @@ }, "node_modules/exponential-backoff": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, "node_modules/express-rate-limit": { "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { "ip-address": "^10.2.0" @@ -9549,17 +10509,21 @@ }, "node_modules/ext": { "version": "1.7.0", - "license": "ISC", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", "dependencies": { "type": "^2.7.2" } }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "license": "MIT" + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-equals": { "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -9567,20 +10531,28 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", "license": "MIT" }, "node_modules/fast-string-width": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", "license": "MIT", "dependencies": { "fast-string-truncated-width": "^3.0.2" @@ -9588,6 +10560,8 @@ }, "node_modules/fast-uri": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -9602,6 +10576,8 @@ }, "node_modules/fast-wrap-ansi": { "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", "license": "MIT", "dependencies": { "fast-string-width": "^3.0.2" @@ -9609,7 +10585,8 @@ }, "node_modules/fdir": { "version": "6.5.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "engines": { "node": ">=12.0.0" }, @@ -9624,6 +10601,8 @@ }, "node_modules/file-entry-cache": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9635,24 +10614,27 @@ }, "node_modules/filelist": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", "dev": true, - "license": "Apache-2.0", "dependencies": { "minimatch": "^5.0.1" } }, "node_modules/filelist/node_modules/brace-expansion": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/filelist/node_modules/minimatch": { "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -9662,8 +10644,9 @@ }, "node_modules/fill-range": { "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, - "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -9673,6 +10656,8 @@ }, "node_modules/finalhandler": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -9692,8 +10677,9 @@ }, "node_modules/find-up": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -9707,6 +10693,8 @@ }, "node_modules/find-yarn-workspace-root": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -9715,6 +10703,8 @@ }, "node_modules/flat-cache": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { @@ -9727,22 +10717,25 @@ }, "node_modules/flatpickr": { "version": "4.6.13", - "license": "MIT" + "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz", + "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==" }, "node_modules/flatted": { "version": "3.4.2", - "dev": true, - "license": "ISC" + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true }, "node_modules/follow-redirects": { "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], - "license": "MIT", "engines": { "node": ">=4.0" }, @@ -9754,6 +10747,8 @@ }, "node_modules/for-each": { "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { @@ -9768,14 +10763,16 @@ }, "node_modules/forwarded": { "version": "0.2.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "engines": { "node": ">= 0.6" } }, "node_modules/fraction.js": { "version": "5.3.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "engines": { "node": "*" }, @@ -9786,6 +10783,8 @@ }, "node_modules/fs-extra": { "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9799,6 +10798,8 @@ }, "node_modules/fs-minipass": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -9809,12 +10810,15 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", - "dev": true, - "license": "ISC" + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true }, "node_modules/fsevents": { "version": "2.3.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, "optional": true, "os": [ "darwin" @@ -9825,15 +10829,17 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/function.prototype.name": { "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -9851,29 +10857,33 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/gensync": { "version": "1.0.0-beta.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "engines": { "node": ">=6.9.0" } }, "node_modules/get-caller-file": { "version": "2.0.5", - "license": "ISC", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "engines": { "node": "6.* || 8.* || >= 10.*" } }, "node_modules/get-east-asian-width": { "version": "1.5.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "engines": { "node": ">=18" }, @@ -9883,6 +10893,8 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -9905,6 +10917,8 @@ }, "node_modules/get-nonce": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", "license": "MIT", "engines": { "node": ">=6" @@ -9912,7 +10926,8 @@ }, "node_modules/get-proto": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -9923,8 +10938,9 @@ }, "node_modules/get-symbol-description": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -9939,6 +10955,8 @@ }, "node_modules/get-tsconfig": { "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -9950,6 +10968,8 @@ }, "node_modules/glob": { "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -9965,18 +10985,22 @@ }, "node_modules/glob-to-regexp": { "version": "0.4.1", - "dev": true, - "license": "BSD-2-Clause" + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true }, "node_modules/glob/node_modules/balanced-match": { "version": "4.0.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "engines": { "node": "18 || 20 || >=22" } }, "node_modules/glob/node_modules/brace-expansion": { "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -9987,7 +11011,8 @@ }, "node_modules/glob/node_modules/minimatch": { "version": "10.2.4", - "license": "BlueOak-1.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dependencies": { "brace-expansion": "^5.0.2" }, @@ -10000,6 +11025,8 @@ }, "node_modules/globals": { "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -10011,8 +11038,9 @@ }, "node_modules/globalthis": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, - "license": "MIT", "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" @@ -10026,7 +11054,8 @@ }, "node_modules/gopd": { "version": "1.2.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "engines": { "node": ">= 0.4" }, @@ -10036,12 +11065,14 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "license": "ISC" + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/gzip-size": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", "dev": true, - "license": "MIT", "dependencies": { "duplexer": "^0.1.2" }, @@ -10054,15 +11085,17 @@ }, "node_modules/hammerjs": { "version": "2.0.8", - "license": "MIT", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", "engines": { "node": ">=0.8.0" } }, "node_modules/has-bigints": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -10072,6 +11105,8 @@ }, "node_modules/has-flag": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "engines": { "node": ">=8" @@ -10079,8 +11114,9 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, - "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" }, @@ -10090,8 +11126,9 @@ }, "node_modules/has-proto": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, - "license": "MIT", "dependencies": { "dunder-proto": "^1.0.0" }, @@ -10104,7 +11141,8 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "engines": { "node": ">= 0.4" }, @@ -10114,8 +11152,9 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, - "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" }, @@ -10128,7 +11167,8 @@ }, "node_modules/hasown": { "version": "2.0.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": { "function-bind": "^1.1.2" }, @@ -10138,26 +11178,31 @@ }, "node_modules/he": { "version": "1.2.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "bin": { "he": "bin/he" } }, "node_modules/hermes-estree": { "version": "0.25.1", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true }, "node_modules/hermes-parser": { "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", "dev": true, - "license": "MIT", "dependencies": { "hermes-estree": "0.25.1" } }, "node_modules/hono": { "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -10165,6 +11210,8 @@ }, "node_modules/hosted-git-info": { "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "license": "ISC", "dependencies": { "lru-cache": "^11.1.0" @@ -10175,6 +11222,8 @@ }, "node_modules/hosted-git-info/node_modules/lru-cache": { "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -10182,6 +11231,8 @@ }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -10193,11 +11244,14 @@ }, "node_modules/html-escaper": { "version": "2.0.2", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true }, "node_modules/html-parse-stringify": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", "license": "MIT", "dependencies": { "void-elements": "3.1.0" @@ -10205,6 +11259,8 @@ }, "node_modules/html-parse-stringify/node_modules/void-elements": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10212,6 +11268,8 @@ }, "node_modules/html-standard": { "version": "0.0.13", + "resolved": "https://registry.npmjs.org/html-standard/-/html-standard-0.0.13.tgz", + "integrity": "sha512-6oNfW3c1t44O7jVXu0tp4E5MbHifWlXrHlZBPt6y7vFdgLOUUh8hyzoRhfUgozlBUK6oLLYhqP1uIqbZ8ggcBA==", "dev": true, "license": "MIT", "dependencies": { @@ -10221,7 +11279,8 @@ }, "node_modules/html2canvas": { "version": "1.4.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" @@ -10232,6 +11291,8 @@ }, "node_modules/htmlparser2": { "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -10250,6 +11311,8 @@ }, "node_modules/htmlparser2/node_modules/entities": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -10261,11 +11324,14 @@ }, "node_modules/http-cache-semantics": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, "node_modules/http-errors": { "version": "2.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", @@ -10283,7 +11349,8 @@ }, "node_modules/http-proxy": { "version": "1.18.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", @@ -10295,6 +11362,8 @@ }, "node_modules/http-proxy-agent": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -10306,7 +11375,8 @@ }, "node_modules/http-server": { "version": "13.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-13.1.0.tgz", + "integrity": "sha512-MLqBMXeY/YN0FYMz4ifeOQCcg8pKj8YdmzX1pr/Vb2VrNnbxHN1s4K9BuZRVSyK/j3DQ8UVrrABb8m6EmFjWog==", "dependencies": { "basic-auth": "^1.0.3", "chalk": "^4.1.2", @@ -10330,7 +11400,8 @@ }, "node_modules/http-server/node_modules/mime": { "version": "1.6.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "bin": { "mime": "cli.js" }, @@ -10340,6 +11411,8 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -10351,7 +11424,8 @@ }, "node_modules/i18n-js": { "version": "4.5.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/i18n-js/-/i18n-js-4.5.1.tgz", + "integrity": "sha512-n7jojFj1WC0tztgr0I8jqTXuIlY1xNzXnC3mjKX/YjJhimdM+jXM8vOmn9d3xQFNC6qDHJ4ovhdrGXrRXLIGkA==", "dependencies": { "bignumber.js": "*", "lodash": "*", @@ -10360,6 +11434,8 @@ }, "node_modules/i18next": { "version": "25.6.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.3.tgz", + "integrity": "sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==", "funding": [ { "type": "individual", @@ -10389,6 +11465,8 @@ }, "node_modules/iconv-lite": { "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -10403,10 +11481,13 @@ }, "node_modules/idiomorph": { "version": "0.7.4", - "license": "0BSD" + "resolved": "https://registry.npmjs.org/idiomorph/-/idiomorph-0.7.4.tgz", + "integrity": "sha512-uCdSpLo3uMfqOmrwXTpR1k/sq4sSmKC7l4o/LdJOEU+MMMq+wkevRqOQYn3lP7vfz9Mv+USBEqPvi0XhdL9ENw==" }, "node_modules/ieee754": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "funding": [ { "type": "github", @@ -10425,6 +11506,8 @@ }, "node_modules/ignore": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -10433,6 +11516,8 @@ }, "node_modules/ignore-walk": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", "license": "ISC", "dependencies": { "minimatch": "^10.0.3" @@ -10443,6 +11528,8 @@ }, "node_modules/ignore-walk/node_modules/balanced-match": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -10450,6 +11537,8 @@ }, "node_modules/ignore-walk/node_modules/brace-expansion": { "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -10460,6 +11549,8 @@ }, "node_modules/ignore-walk/node_modules/minimatch": { "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -10473,19 +11564,23 @@ }, "node_modules/immutable": { "version": "5.1.5", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true }, "node_modules/imurmurhash": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.8.19" } }, "node_modules/indent-string": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, "license": "MIT", "engines": { @@ -10494,8 +11589,9 @@ }, "node_modules/inflight": { "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "dev": true, - "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -10503,10 +11599,13 @@ }, "node_modules/inherits": { "version": "2.0.4", - "license": "ISC" + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -10514,8 +11613,9 @@ }, "node_modules/internal-slot": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, - "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", @@ -10527,6 +11627,8 @@ }, "node_modules/ip-address": { "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -10534,12 +11636,14 @@ }, "node_modules/is-array": { "version": "1.0.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/is-array/-/is-array-1.0.1.tgz", + "integrity": "sha512-gxiZ+y/u67AzpeFmAmo4CbtME/bs7J2C++su5zQzvQyaxUqVzkh69DI+jN+KZuSO6JaH6TIIU6M6LhqxMjxEpw==" }, "node_modules/is-array-buffer": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -10554,8 +11658,9 @@ }, "node_modules/is-async-function": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.0.tgz", + "integrity": "sha512-GExz9MtyhlZyXYLxzlJRj5WUCE661zhDa1Yna52CN57AJsymh+DvXXjyveSioqSRdxvUrdKdvqB1b5cVKsNpWQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.1", @@ -10571,8 +11676,9 @@ }, "node_modules/is-bigint": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, - "license": "MIT", "dependencies": { "has-bigints": "^1.0.2" }, @@ -10585,8 +11691,9 @@ }, "node_modules/is-boolean-object": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.1.tgz", + "integrity": "sha512-l9qO6eFlUETHtuihLcYOaLKByJ1f+N4kthcU9YjHy3N+B3hWv0y/2Nd0mu/7lTFnRQHTrSdXF50HQ3bl5fEnng==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" @@ -10600,8 +11707,9 @@ }, "node_modules/is-callable": { "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -10611,6 +11719,8 @@ }, "node_modules/is-core-module": { "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { @@ -10625,8 +11735,9 @@ }, "node_modules/is-data-view": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", @@ -10641,8 +11752,9 @@ }, "node_modules/is-date-object": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" @@ -10656,8 +11768,9 @@ }, "node_modules/is-docker": { "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "dev": true, - "license": "MIT", "bin": { "is-docker": "cli.js" }, @@ -10670,16 +11783,18 @@ }, "node_modules/is-extglob": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/is-finalizationregistry": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3" }, @@ -10692,16 +11807,18 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/is-generator-function": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", @@ -10717,8 +11834,9 @@ }, "node_modules/is-glob": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -10728,6 +11846,8 @@ }, "node_modules/is-interactive": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", "license": "MIT", "engines": { "node": ">=12" @@ -10738,8 +11858,9 @@ }, "node_modules/is-map": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -10749,6 +11870,8 @@ }, "node_modules/is-negative-zero": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "license": "MIT", "engines": { @@ -10760,8 +11883,9 @@ }, "node_modules/is-number-object": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -10775,17 +11899,22 @@ }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, "license": "MIT" }, "node_modules/is-promise": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, "node_modules/is-regex": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", @@ -10801,8 +11930,9 @@ }, "node_modules/is-set": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -10812,8 +11942,9 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3" }, @@ -10826,8 +11957,9 @@ }, "node_modules/is-string": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -10841,8 +11973,9 @@ }, "node_modules/is-symbol": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", @@ -10857,8 +11990,9 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, - "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" }, @@ -10871,6 +12005,8 @@ }, "node_modules/is-unicode-supported": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "license": "MIT", "engines": { "node": ">=18" @@ -10881,8 +12017,9 @@ }, "node_modules/is-weakmap": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -10892,6 +12029,8 @@ }, "node_modules/is-weakref": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { @@ -10906,8 +12045,9 @@ }, "node_modules/is-weakset": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" @@ -10921,8 +12061,9 @@ }, "node_modules/is-wsl": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, - "license": "MIT", "dependencies": { "is-docker": "^2.0.0" }, @@ -10932,19 +12073,24 @@ }, "node_modules/isarray": { "version": "2.0.5", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true }, "node_modules/iselement": { "version": "1.1.4", - "license": "MIT" + "resolved": "https://registry.npmjs.org/iselement/-/iselement-1.1.4.tgz", + "integrity": "sha512-4Q519eWmbHO1pbimiz7H1iJRUHVmAmfh0viSsUD+oAwVO4ntZt7gpf8i8AShVBTyOvRTZNYNBpUxOIvwZR+ffw==" }, "node_modules/isexe": { "version": "2.0.0", - "license": "ISC" + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/isomorphic.js": { "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", "funding": { "type": "GitHub Sponsors ❤", @@ -10953,16 +12099,18 @@ }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=8" } }, "node_modules/istanbul-lib-report": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", @@ -10974,8 +12122,9 @@ }, "node_modules/istanbul-lib-report/node_modules/make-dir": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, - "license": "MIT", "dependencies": { "semver": "^7.5.3" }, @@ -10988,6 +12137,8 @@ }, "node_modules/istanbul-reports": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -11000,8 +12151,9 @@ }, "node_modules/iterator.prototype": { "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, - "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", @@ -11016,8 +12168,9 @@ }, "node_modules/jake": { "version": "10.8.7", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", + "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", "dev": true, - "license": "Apache-2.0", "dependencies": { "async": "^3.2.3", "chalk": "^4.0.2", @@ -11033,6 +12186,8 @@ }, "node_modules/jiti": { "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -11041,6 +12196,8 @@ }, "node_modules/jose": { "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -11048,10 +12205,14 @@ }, "node_modules/jquery": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz", + "integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==", "license": "MIT" }, "node_modules/jquery-migrate": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jquery-migrate/-/jquery-migrate-4.0.2.tgz", + "integrity": "sha512-J15ilpECoAsObLv2robklj0qf1qt9QCnZwwNcJAXkM7dIiOgcac2dqC3HgDtUF0fmRwKZa/ggTkxTj9S4Vk+HQ==", "license": "MIT", "peerDependencies": { "jquery": ">=4 <5" @@ -11059,10 +12220,13 @@ }, "node_modules/js-tokens": { "version": "4.0.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "funding": [ { "type": "github", @@ -11083,10 +12247,13 @@ }, "node_modules/js-yaml/node_modules/argparse": { "version": "2.0.1", - "license": "Python-2.0" + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/jsdom": { "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -11126,6 +12293,8 @@ }, "node_modules/jsdom/node_modules/lru-cache": { "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -11134,6 +12303,8 @@ }, "node_modules/jsesc": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -11144,11 +12315,15 @@ }, "node_modules/json-buffer": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", "license": "MIT", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -11156,14 +12331,19 @@ }, "node_modules/json-schema-traverse": { "version": "1.0.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/json-schema-typed": { "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, "node_modules/json-stable-stringify": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", "dev": true, "license": "MIT", "dependencies": { @@ -11182,12 +12362,15 @@ }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": { "json5": "lib/cli.js" }, @@ -11197,10 +12380,13 @@ }, "node_modules/jsonc-parser": { "version": "3.3.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" }, "node_modules/jsonfile": { "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -11212,6 +12398,8 @@ }, "node_modules/jsonify": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", "dev": true, "license": "Public Domain", "funding": { @@ -11220,6 +12408,8 @@ }, "node_modules/jsonparse": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", "engines": [ "node >= 0.2.0" ], @@ -11227,8 +12417,9 @@ }, "node_modules/jsx-ast-utils": { "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, - "license": "MIT", "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", @@ -11241,6 +12432,8 @@ }, "node_modules/keyv": { "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -11249,6 +12442,8 @@ }, "node_modules/klaw-sync": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11257,17 +12452,20 @@ }, "node_modules/ktx-parse": { "version": "0.7.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz", + "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==" }, "node_modules/language-subtag-registry": { "version": "0.3.22", - "dev": true, - "license": "CC0-1.0" + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", + "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", + "dev": true }, "node_modules/language-tags": { "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, - "license": "MIT", "dependencies": { "language-subtag-registry": "^0.3.20" }, @@ -11277,10 +12475,13 @@ }, "node_modules/laz-perf": { "version": "0.0.6", - "license": "Apache-2.0" + "resolved": "https://registry.npmjs.org/laz-perf/-/laz-perf-0.0.6.tgz", + "integrity": "sha512-ZBqC+BBlofznDIY3SfjXDBVdIhYfz7bq8HAHztlw4XOnu++nHiWtCGPgzpdeAhPkByc68DaKNy3E3rY4XrdRtQ==" }, "node_modules/levn": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11293,6 +12494,8 @@ }, "node_modules/lib0": { "version": "0.2.109", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.109.tgz", + "integrity": "sha512-jP0gbnyW0kwlx1Atc4dcHkBbrVAkdHjuyHxtClUPYla7qCmwIif1qZ6vQeJdR5FrOVdn26HvQT0ko01rgW7/Xw==", "license": "MIT", "dependencies": { "isomorphic.js": "^0.2.4" @@ -11312,6 +12515,8 @@ }, "node_modules/listr2": { "version": "10.2.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", "license": "MIT", "dependencies": { "cli-truncate": "^5.2.0", @@ -11326,6 +12531,8 @@ }, "node_modules/listr2/node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -11336,6 +12543,8 @@ }, "node_modules/listr2/node_modules/ansi-styles": { "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -11346,10 +12555,14 @@ }, "node_modules/listr2/node_modules/eventemitter3": { "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/listr2/node_modules/string-width": { "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -11364,6 +12577,8 @@ }, "node_modules/listr2/node_modules/strip-ansi": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -11377,6 +12592,8 @@ }, "node_modules/listr2/node_modules/wrap-ansi": { "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.3", @@ -11392,6 +12609,8 @@ }, "node_modules/lit": { "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz", + "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", "license": "BSD-3-Clause", "dependencies": { "@lit/reactive-element": "^1.6.0", @@ -11401,6 +12620,8 @@ }, "node_modules/lit-element": { "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz", + "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", "license": "BSD-3-Clause", "dependencies": { "@lit-labs/ssr-dom-shim": "^1.1.0", @@ -11410,6 +12631,8 @@ }, "node_modules/lit-element/node_modules/lit-html": { "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", + "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", "license": "BSD-3-Clause", "dependencies": { "@types/trusted-types": "^2.0.2" @@ -11417,6 +12640,8 @@ }, "node_modules/lit-html": { "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", "license": "BSD-3-Clause", "dependencies": { "@types/trusted-types": "^2.0.2" @@ -11424,6 +12649,8 @@ }, "node_modules/lit/node_modules/lit-html": { "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", + "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", "license": "BSD-3-Clause", "dependencies": { "@types/trusted-types": "^2.0.2" @@ -11431,6 +12658,8 @@ }, "node_modules/lmdb": { "version": "3.5.4", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.4.tgz", + "integrity": "sha512-9FKQA6G1MMtqNxfxvSBNXD/axeG2QRjYbNh0/ykRL5xYcRbCm2vXq7B9bhc7nSuKdHzr8/BHIwfPuYYH1UsXXw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -11458,8 +12687,9 @@ }, "node_modules/locate-path": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -11472,34 +12702,49 @@ }, "node_modules/lodash": { "version": "4.18.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" }, "node_modules/lodash-es": { "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.clonedeep": { "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", "license": "MIT" }, "node_modules/lodash.clonedeepwith": { "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeepwith/-/lodash.clonedeepwith-4.5.0.tgz", + "integrity": "sha512-QRBRSxhbtsX1nc0baxSkkK5WlVTTm/s48DSukcGcWZwIyI8Zz+lB+kFiELJXtzfH4Aj6kMWQ1VWW4U5uUDgZMA==", "license": "MIT" }, "node_modules/lodash.get": { "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", - "license": "MIT" + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, "node_modules/lodash.take": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.take/-/lodash.take-4.1.1.tgz", + "integrity": "sha512-3T118EQjnhr9c0aBKCCMhQn0OBwRMz/O2WaRU6VH0TSKoMCmFtUpr0iUp+eWKODEiRXtYOK7R7SiBneKHdk7og==", "license": "MIT" }, "node_modules/log-symbols": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", "license": "MIT", "dependencies": { "is-unicode-supported": "^2.0.0", @@ -11514,6 +12759,8 @@ }, "node_modules/log-update": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "license": "MIT", "dependencies": { "ansi-escapes": "^7.0.0", @@ -11531,6 +12778,8 @@ }, "node_modules/log-update/node_modules/ansi-escapes": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", "license": "MIT", "dependencies": { "environment": "^1.0.0" @@ -11544,6 +12793,8 @@ }, "node_modules/log-update/node_modules/ansi-regex": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "license": "MIT", "engines": { "node": ">=12" @@ -11554,6 +12805,8 @@ }, "node_modules/log-update/node_modules/ansi-styles": { "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "license": "MIT", "engines": { "node": ">=12" @@ -11564,10 +12817,14 @@ }, "node_modules/log-update/node_modules/emoji-regex": { "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, "node_modules/log-update/node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -11583,6 +12840,8 @@ }, "node_modules/log-update/node_modules/strip-ansi": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -11596,6 +12855,8 @@ }, "node_modules/log-update/node_modules/wrap-ansi": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -11611,8 +12872,9 @@ }, "node_modules/loose-envify": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dev": true, - "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -11622,20 +12884,24 @@ }, "node_modules/lru-cache": { "version": "5.1.1", - "license": "ISC", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dependencies": { "yallist": "^3.0.2" } }, "node_modules/lru-queue": { "version": "0.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", "dependencies": { "es5-ext": "~0.10.2" } }, "node_modules/luxon": { "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", "license": "MIT", "engines": { "node": ">=12" @@ -11643,6 +12909,8 @@ }, "node_modules/lz-string": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", "bin": { @@ -11651,6 +12919,8 @@ }, "node_modules/magic-string": { "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -11658,6 +12928,8 @@ }, "node_modules/magicast": { "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11668,11 +12940,14 @@ }, "node_modules/make-error": { "version": "1.3.6", - "dev": true, - "license": "ISC" + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true }, "node_modules/make-fetch-happen": { "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", @@ -11694,10 +12969,13 @@ }, "node_modules/make-plural": { "version": "7.3.0", - "license": "Unicode-DFS-2016" + "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.3.0.tgz", + "integrity": "sha512-/K3BC0KIsO+WK2i94LkMPv3wslMrazrQhfi5We9fMbLlLjzoOSJWr7TAdupLlDWaJcWxwoNosBkhFDejiu5VDw==" }, "node_modules/marked": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -11708,19 +12986,23 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "engines": { "node": ">= 0.4" } }, "node_modules/mdn-data": { "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, "license": "CC0-1.0" }, "node_modules/mdx-embed": { "version": "1.1.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/mdx-embed/-/mdx-embed-1.1.2.tgz", + "integrity": "sha512-AAronHC/sh4py+RhJOuX8+9+lyUbJsmCLquXNPCEHZ0llPWjMuwls77hII/lWI2kwBSPZPahrqti8kGTv1pi1w==", "peerDependencies": { "@mdx-js/mdx": "^1.6.16", "@mdx-js/react": "^1.6.16", @@ -11730,7 +13012,8 @@ }, "node_modules/memoizee": { "version": "0.3.10", - "license": "MIT", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.3.10.tgz", + "integrity": "sha512-LLzVUuWwGBKK188spgOK/ukrp5zvd9JGsiLDH41pH9vt5jvhZfsu5pxDuAnYAMG8YEGce72KO07sSBy9KkvOfw==", "dependencies": { "d": "~0.1.1", "es5-ext": "~0.10.11", @@ -11743,12 +13026,14 @@ }, "node_modules/memoizee/node_modules/next-tick": { "version": "0.2.2", - "license": "MIT" + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz", + "integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==" }, "node_modules/micromatch": { "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, - "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -11759,10 +13044,11 @@ }, "node_modules/mime": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", "funding": [ "https://github.com/sponsors/broofa" ], - "license": "MIT", "bin": { "mime": "bin/cli.js" }, @@ -11772,6 +13058,8 @@ }, "node_modules/mimic-function": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "license": "MIT", "engines": { "node": ">=18" @@ -11782,6 +13070,8 @@ }, "node_modules/min-indent": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, "license": "MIT", "engines": { @@ -11790,8 +13080,9 @@ }, "node_modules/minimatch": { "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -11801,13 +13092,16 @@ }, "node_modules/minimist": { "version": "1.2.8", - "license": "MIT", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/minipass": { "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -11815,6 +13109,8 @@ }, "node_modules/minipass-collect": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -11825,6 +13121,8 @@ }, "node_modules/minipass-fetch": { "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", "license": "MIT", "dependencies": { "minipass": "^7.0.3", @@ -11840,6 +13138,8 @@ }, "node_modules/minipass-flush": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", "license": "BlueOak-1.0.0", "dependencies": { "minipass": "^3.0.0" @@ -11850,6 +13150,8 @@ }, "node_modules/minipass-flush/node_modules/minipass": { "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -11860,10 +13162,14 @@ }, "node_modules/minipass-flush/node_modules/yallist": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, "node_modules/minipass-pipeline": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", "license": "ISC", "dependencies": { "minipass": "^3.0.0" @@ -11874,6 +13180,8 @@ }, "node_modules/minipass-pipeline/node_modules/minipass": { "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -11884,10 +13192,14 @@ }, "node_modules/minipass-pipeline/node_modules/yallist": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, "node_modules/minipass-sized": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", "license": "ISC", "dependencies": { "minipass": "^7.1.2" @@ -11898,6 +13210,8 @@ }, "node_modules/minizlib": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -11908,7 +13222,8 @@ }, "node_modules/mkdirp": { "version": "0.5.6", - "license": "MIT", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dependencies": { "minimist": "^1.2.6" }, @@ -11918,13 +13233,16 @@ }, "node_modules/moment": { "version": "2.30.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "engines": { "node": "*" } }, "node_modules/moment-timezone": { "version": "0.6.2", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.6.2.tgz", + "integrity": "sha512-lDsQv8FoGdBUdf0+TjGsq2orxKuXdwFlQ6Zw6TX3xIcTwTfEpCLyKqvEauvCHJ8iu3KBV8+uPhlv70YsNGdUBQ==", "license": "MIT", "dependencies": { "moment": "^2.29.4" @@ -11935,10 +13253,13 @@ }, "node_modules/mousetrap": { "version": "1.6.5", - "license": "Apache-2.0 WITH LLVM-exception" + "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz", + "integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==" }, "node_modules/mrmime": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "license": "MIT", "engines": { @@ -11947,10 +13268,13 @@ }, "node_modules/ms": { "version": "2.1.3", - "license": "MIT" + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/msgpackr": { "version": "1.11.13", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.13.tgz", + "integrity": "sha512-pWaxg0k1iiNdkAayUQ7Zlz/vYNfVefUttmHxqFcQjjtyqFa3w4x5rginOEzy/GvbWhBDD9K65/ZXyq8qz8utaQ==", "dev": true, "license": "MIT", "optional": true, @@ -11960,6 +13284,8 @@ }, "node_modules/msgpackr-extract": { "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -11981,6 +13307,8 @@ }, "node_modules/mute-stream": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, "license": "ISC", "engines": { @@ -11989,6 +13317,8 @@ }, "node_modules/nanoid": { "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -11996,7 +13326,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -12006,11 +13335,14 @@ }, "node_modules/natural-compare": { "version": "1.4.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true }, "node_modules/negotiator": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -12018,11 +13350,13 @@ }, "node_modules/next-tick": { "version": "1.1.0", - "license": "ISC" + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, "node_modules/ng-dynamic-component": { "version": "10.7.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/ng-dynamic-component/-/ng-dynamic-component-10.7.0.tgz", + "integrity": "sha512-shkht4L2nA632qo5GNendZdQXoHUBuKz6SLLh20qi5oESBm3b5AWyhHu0fb/7tb+f+zAcwtVVe1M0819/bnK9A==", "dependencies": { "tslib": "^2.0.0" }, @@ -12034,7 +13368,8 @@ }, "node_modules/ng2-charts": { "version": "10.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/ng2-charts/-/ng2-charts-10.0.0.tgz", + "integrity": "sha512-mdL75XJrk/0s0YO2ySPQpAHPja85ECDEGNWFlcElJiy/bYliTNGEpeCtctAqZuozTff/E2CwGjyfPFM1ScP2og==", "dependencies": { "es-toolkit": "^1.39.7", "tslib": "^2.3.0" @@ -12050,7 +13385,8 @@ }, "node_modules/ng2-dragula": { "version": "6.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/ng2-dragula/-/ng2-dragula-6.0.0.tgz", + "integrity": "sha512-DRWw0rTZrpIReecgE4771flOrpa3rgSkY3TDyZBNPBEkPIAFOePfPm9W96IhlWEXgQEL7fjWaz2HyZI89YGDUg==", "dependencies": { "tslib": "^2.3.0" }, @@ -12065,6 +13401,8 @@ }, "node_modules/ngx-cookie-service": { "version": "21.3.1", + "resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-21.3.1.tgz", + "integrity": "sha512-8VEA2W7W2W3yPXhemJoVtXxr+3WW2DNLV4OaCIKDzLdzUUxJ6SzPHMmXXa26Pg8pa+fZxHK1hZfqJfUxr9RMBw==", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -12076,12 +13414,16 @@ }, "node_modules/node-addon-api": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true, "license": "MIT", "optional": true }, "node_modules/node-gyp": { "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", "license": "MIT", "dependencies": { "env-paths": "^2.2.0", @@ -12104,6 +13446,8 @@ }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", "dev": true, "license": "MIT", "optional": true, @@ -12118,6 +13462,8 @@ }, "node_modules/node-gyp/node_modules/isexe": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", "license": "BlueOak-1.0.0", "engines": { "node": ">=20" @@ -12125,6 +13471,8 @@ }, "node_modules/node-gyp/node_modules/undici": { "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "license": "MIT", "engines": { "node": ">=18.17" @@ -12132,6 +13480,8 @@ }, "node_modules/node-gyp/node_modules/which": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "license": "ISC", "dependencies": { "isexe": "^4.0.0" @@ -12154,6 +13504,8 @@ }, "node_modules/nopt": { "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "license": "ISC", "dependencies": { "abbrev": "^4.0.0" @@ -12167,6 +13519,8 @@ }, "node_modules/npm-bundled": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", "license": "ISC", "dependencies": { "npm-normalize-package-bin": "^5.0.0" @@ -12177,6 +13531,8 @@ }, "node_modules/npm-install-checks": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", "license": "BSD-2-Clause", "dependencies": { "semver": "^7.1.1" @@ -12187,6 +13543,8 @@ }, "node_modules/npm-normalize-package-bin": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -12194,6 +13552,8 @@ }, "node_modules/npm-package-arg": { "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", "license": "ISC", "dependencies": { "hosted-git-info": "^9.0.0", @@ -12207,6 +13567,8 @@ }, "node_modules/npm-packlist": { "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", "license": "ISC", "dependencies": { "ignore-walk": "^8.0.0", @@ -12218,6 +13580,8 @@ }, "node_modules/npm-pick-manifest": { "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", "license": "ISC", "dependencies": { "npm-install-checks": "^8.0.0", @@ -12231,6 +13595,8 @@ }, "node_modules/npm-registry-fetch": { "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", "license": "ISC", "dependencies": { "@npmcli/redact": "^4.0.0", @@ -12248,6 +13614,8 @@ }, "node_modules/nth-check": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -12259,13 +13627,16 @@ }, "node_modules/object-assign": { "version": "4.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "engines": { "node": ">=0.10.0" } }, "node_modules/object-inspect": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -12276,16 +13647,18 @@ }, "node_modules/object-keys": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/object.assign": { "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -12303,6 +13676,8 @@ }, "node_modules/object.entries": { "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, "license": "MIT", "dependencies": { @@ -12317,8 +13692,9 @@ }, "node_modules/object.fromentries": { "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -12334,8 +13710,9 @@ }, "node_modules/object.groupby": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -12347,8 +13724,9 @@ }, "node_modules/object.values": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -12364,7 +13742,8 @@ }, "node_modules/observable-array": { "version": "0.0.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/observable-array/-/observable-array-0.0.4.tgz", + "integrity": "sha512-sZYd1/Vw5jIXkmV6IGe/JU0Ked5RejTGbBJju6nZorNQXRH41XN5NRMVfC4O4hdXTV89LgnFGvljHrWx4/Q+/A==", "dependencies": { "d": "^0.1.1", "es5-ext": "^0.10.8", @@ -12375,7 +13754,8 @@ }, "node_modules/observable-value": { "version": "0.0.5", - "license": "MIT", + "resolved": "https://registry.npmjs.org/observable-value/-/observable-value-0.0.5.tgz", + "integrity": "sha512-A550SjCfty4Kqe9lWx/G0y7H0T+V6FXs/rFxjGUoxsjh4TRp/ykD6atHTvNDbEPfh9OG9BLTn1CG2b1IJNSOWg==", "dependencies": { "d": "^0.1.1", "es5-ext": "^0.10.8", @@ -12386,6 +13766,8 @@ }, "node_modules/obug": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -12395,7 +13777,8 @@ }, "node_modules/on-finished": { "version": "2.4.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "dependencies": { "ee-first": "1.1.1" }, @@ -12405,7 +13788,8 @@ }, "node_modules/once": { "version": "1.4.0", - "license": "ISC", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dependencies": { "wrappy": "1" } @@ -12436,6 +13820,8 @@ }, "node_modules/openapi-data-validator": { "version": "2.0.54", + "resolved": "https://registry.npmjs.org/openapi-data-validator/-/openapi-data-validator-2.0.54.tgz", + "integrity": "sha512-JsqqILn+NwgsjA2wJsY+qvVqtkaWz7cnf7gbT+1Nm54+NSsQNguV+fd3Y6WefTkLug6PLws+zylfZ7QjD0s71Q==", "license": "Apache-2.0", "dependencies": { "@apidevtools/json-schema-ref-parser": "^9.0", @@ -12453,6 +13839,8 @@ }, "node_modules/openapi-data-validator/node_modules/media-typer": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -12460,6 +13848,8 @@ }, "node_modules/openapi-explorer": { "version": "2.4.801", + "resolved": "https://registry.npmjs.org/openapi-explorer/-/openapi-explorer-2.4.801.tgz", + "integrity": "sha512-HuoKXTvGOe+YG5mJq2kMCmfBQj87MN9YuNvuCCJV5BlJzSnNZ3xjZC2q7lPbc7MGZUCrRaHzAbxNChEN8kiEAg==", "license": "Apache-2.0", "dependencies": { "@authress/login": "^2.0", @@ -12485,6 +13875,8 @@ }, "node_modules/openapi-explorer/node_modules/i18next": { "version": "21.10.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz", + "integrity": "sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg==", "funding": [ { "type": "individual", @@ -12506,6 +13898,8 @@ }, "node_modules/openapi-resolver": { "version": "4.2.4", + "resolved": "https://registry.npmjs.org/openapi-resolver/-/openapi-resolver-4.2.4.tgz", + "integrity": "sha512-AjR/hIVI5qfyI6VmTjpQIXjTmxmWjqXqxSqkRdeWQrUATYsTVNQ3uSxK8NJ7QEXwfiKyID/iWf9iPuBxoWWoiA==", "license": "Apache-2.0", "dependencies": { "@apidevtools/json-schema-ref-parser": "^9.0.9", @@ -12516,13 +13910,16 @@ }, "node_modules/opener": { "version": "1.5.2", - "license": "(WTFPL OR MIT)", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", "bin": { "opener": "bin/opener-bin.js" } }, "node_modules/optionator": { "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -12539,6 +13936,8 @@ }, "node_modules/ora": { "version": "9.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz", + "integrity": "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -12559,6 +13958,8 @@ }, "node_modules/ora/node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -12569,6 +13970,8 @@ }, "node_modules/ora/node_modules/chalk": { "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -12579,6 +13982,8 @@ }, "node_modules/ora/node_modules/string-width": { "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -12593,6 +13998,8 @@ }, "node_modules/ora/node_modules/strip-ansi": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -12606,18 +14013,23 @@ }, "node_modules/ordered-binary": { "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", "dev": true, "license": "MIT", "optional": true }, "node_modules/orderedmap": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, "node_modules/own-keys": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "dev": true, - "license": "MIT", "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", @@ -12632,8 +14044,9 @@ }, "node_modules/p-limit": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, - "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -12646,8 +14059,9 @@ }, "node_modules/p-locate": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -12660,6 +14074,8 @@ }, "node_modules/p-map": { "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "license": "MIT", "engines": { "node": ">=18" @@ -12670,6 +14086,8 @@ }, "node_modules/pacote": { "version": "21.5.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz", + "integrity": "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==", "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", @@ -12699,10 +14117,13 @@ }, "node_modules/pako": { "version": "2.1.0", - "license": "(MIT AND Zlib)" + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" }, "node_modules/parse5": { "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "license": "MIT", "dependencies": { "entities": "^8.0.0" @@ -12713,6 +14134,8 @@ }, "node_modules/parse5-html-rewriting-stream": { "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.1.tgz", + "integrity": "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw==", "license": "MIT", "dependencies": { "entities": "^8.0.0", @@ -12725,6 +14148,8 @@ }, "node_modules/parse5-html-rewriting-stream/node_modules/entities": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -12735,7 +14160,8 @@ }, "node_modules/parse5-sax-parser": { "version": "8.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", + "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", "dependencies": { "parse5": "^8.0.0" }, @@ -12745,6 +14171,8 @@ }, "node_modules/parse5/node_modules/entities": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -12755,13 +14183,16 @@ }, "node_modules/parseurl": { "version": "1.3.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "engines": { "node": ">= 0.8" } }, "node_modules/patch-package": { "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", "dev": true, "license": "MIT", "dependencies": { @@ -12790,6 +14221,8 @@ }, "node_modules/patch-package/node_modules/open": { "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "dev": true, "license": "MIT", "dependencies": { @@ -12805,34 +14238,40 @@ }, "node_modules/path-exists": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/path-is-absolute": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/path-key": { "version": "3.1.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "engines": { "node": ">=8" } }, "node_modules/path-parse": { "version": "1.0.7", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true }, "node_modules/path-scurry": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -12847,6 +14286,8 @@ }, "node_modules/path-scurry/node_modules/lru-cache": { "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -12854,15 +14295,21 @@ }, "node_modules/pathe": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -12887,19 +14334,21 @@ }, "node_modules/pkce-challenge": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "license": "MIT", "engines": { "node": ">=16.20.0" } }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -12912,9 +14361,9 @@ } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -12926,7 +14375,10 @@ }, "node_modules/playwright/node_modules/fsevents": { "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -12938,6 +14390,8 @@ }, "node_modules/pngjs": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", "dev": true, "license": "MIT", "engines": { @@ -12946,7 +14400,8 @@ }, "node_modules/portfinder": { "version": "1.0.32", - "license": "MIT", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.32.tgz", + "integrity": "sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==", "dependencies": { "async": "^2.6.4", "debug": "^3.2.7", @@ -12958,28 +14413,33 @@ }, "node_modules/portfinder/node_modules/async": { "version": "2.6.4", - "license": "MIT", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", "dependencies": { "lodash": "^4.17.14" } }, "node_modules/portfinder/node_modules/debug": { "version": "3.2.7", - "license": "MIT", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dependencies": { "ms": "^2.1.1" } }, "node_modules/possible-typed-array-names": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/postcss": { "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "dev": true, "funding": [ { @@ -13007,11 +14467,15 @@ }, "node_modules/postcss-media-query-parser": { "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "dev": true, "license": "MIT" }, "node_modules/postcss-safe-parser": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", "dev": true, "funding": [ { @@ -13037,11 +14501,13 @@ }, "node_modules/postcss-value-parser": { "version": "4.2.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, "node_modules/preact": { "version": "10.12.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -13049,6 +14515,8 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -13057,6 +14525,8 @@ }, "node_modules/pretty-format": { "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13070,6 +14540,8 @@ }, "node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "engines": { @@ -13081,6 +14553,8 @@ }, "node_modules/prismjs": { "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", "license": "MIT", "engines": { "node": ">=6" @@ -13088,6 +14562,8 @@ }, "node_modules/proc-log": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -13095,8 +14571,9 @@ }, "node_modules/prop-types": { "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "dev": true, - "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -13105,11 +14582,14 @@ }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true }, "node_modules/prosemirror-changeset": { "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", "license": "MIT", "dependencies": { "prosemirror-transform": "^1.0.0" @@ -13117,6 +14597,8 @@ }, "node_modules/prosemirror-commands": { "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", @@ -13126,6 +14608,8 @@ }, "node_modules/prosemirror-dropcursor": { "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", "license": "MIT", "dependencies": { "prosemirror-state": "^1.0.0", @@ -13135,6 +14619,8 @@ }, "node_modules/prosemirror-gapcursor": { "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", "license": "MIT", "dependencies": { "prosemirror-keymap": "^1.0.0", @@ -13145,6 +14631,8 @@ }, "node_modules/prosemirror-highlight": { "version": "0.15.1", + "resolved": "https://registry.npmjs.org/prosemirror-highlight/-/prosemirror-highlight-0.15.1.tgz", + "integrity": "sha512-KcJUGNgqLED+eK/cisNtY3M+eDNLkZyWCdyi7B3RoW3rKHnhkKawnJAcr9p1F/e3q+oDB5Y5OiIrC11bxP7tFA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ocavue" @@ -13204,6 +14692,8 @@ }, "node_modules/prosemirror-history": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", "license": "MIT", "dependencies": { "prosemirror-state": "^1.2.2", @@ -13214,6 +14704,8 @@ }, "node_modules/prosemirror-inputrules": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", "license": "MIT", "dependencies": { "prosemirror-state": "^1.0.0", @@ -13222,6 +14714,8 @@ }, "node_modules/prosemirror-keymap": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", "license": "MIT", "dependencies": { "prosemirror-state": "^1.0.0", @@ -13230,6 +14724,8 @@ }, "node_modules/prosemirror-model": { "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" @@ -13237,6 +14733,8 @@ }, "node_modules/prosemirror-schema-list": { "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", @@ -13246,6 +14744,8 @@ }, "node_modules/prosemirror-state": { "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", @@ -13255,6 +14755,8 @@ }, "node_modules/prosemirror-tables": { "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", "license": "MIT", "dependencies": { "prosemirror-keymap": "^1.2.3", @@ -13266,6 +14768,8 @@ }, "node_modules/prosemirror-transform": { "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" @@ -13273,6 +14777,8 @@ }, "node_modules/prosemirror-view": { "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.20.0", @@ -13282,7 +14788,8 @@ }, "node_modules/proxy-addr": { "version": "2.0.7", - "license": "MIT", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -13293,13 +14800,16 @@ }, "node_modules/proxy-addr/node_modules/ipaddr.js": { "version": "1.9.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "engines": { "node": ">= 0.10" } }, "node_modules/punycode": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -13308,10 +14818,13 @@ }, "node_modules/qr-creator": { "version": "1.0.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/qr-creator/-/qr-creator-1.0.0.tgz", + "integrity": "sha512-C0cqfbS1P5hfqN4NhsYsUXePlk9BO+a45bAQ3xLYjBL3bOIFzoVEjs79Fado9u9BPBD3buHi3+vY+C8tHh4qMQ==" }, "node_modules/qs": { "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -13325,10 +14838,14 @@ }, "node_modules/raf-schd": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", "license": "MIT" }, "node_modules/randexp": { "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", "license": "MIT", "dependencies": { "drange": "^1.0.2", @@ -13340,13 +14857,16 @@ }, "node_modules/range-parser": { "version": "1.2.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "engines": { "node": ">= 0.6" } }, "node_modules/react": { "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13354,6 +14874,8 @@ }, "node_modules/react-dom": { "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -13364,6 +14886,8 @@ }, "node_modules/react-i18next": { "version": "16.3.5", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz", + "integrity": "sha512-F7Kglc+T0aE6W2rO5eCAFBEuWRpNb5IFmXOYEgztjZEuiuSLTe/xBIEG6Q3S0fbl8GXMNo+Q7gF8bpokFNWJww==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.27.6", @@ -13389,6 +14913,8 @@ }, "node_modules/react-icons": { "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", "license": "MIT", "peerDependencies": { "react": "*" @@ -13396,11 +14922,15 @@ }, "node_modules/react-is": { "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, "license": "MIT" }, "node_modules/react-number-format": { "version": "5.4.5", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz", + "integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==", "license": "MIT", "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", @@ -13409,6 +14939,8 @@ }, "node_modules/react-remove-scroll": { "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", @@ -13432,6 +14964,8 @@ }, "node_modules/react-remove-scroll-bar": { "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", "license": "MIT", "dependencies": { "react-style-singleton": "^2.2.2", @@ -13452,6 +14986,8 @@ }, "node_modules/react-style-singleton": { "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", "license": "MIT", "dependencies": { "get-nonce": "^1.0.0", @@ -13472,6 +15008,8 @@ }, "node_modules/read": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/read/-/read-4.1.0.tgz", + "integrity": "sha512-uRfX6K+f+R8OOrYScaM3ixPY4erg69f8DN6pgTvMcA9iRc8iDhwrA4m3Yu8YYKsXJgVvum+m8PkRboZwwuLzYA==", "dev": true, "license": "ISC", "dependencies": { @@ -13483,6 +15021,8 @@ }, "node_modules/redent": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dev": true, "license": "MIT", "dependencies": { @@ -13495,12 +15035,14 @@ }, "node_modules/reflect-metadata": { "version": "0.2.2", - "license": "Apache-2.0" + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -13520,8 +15062,9 @@ }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -13539,6 +15082,8 @@ }, "node_modules/repeat-string": { "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", "license": "MIT", "engines": { "node": ">=0.10" @@ -13546,25 +15091,30 @@ }, "node_modules/require-directory": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/require-from-string": { "version": "2.0.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "engines": { "node": ">=0.10.0" } }, "node_modules/requires-port": { "version": "1.0.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, "node_modules/resolve": { "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13584,6 +15134,8 @@ }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, "license": "MIT", "funding": { @@ -13592,6 +15144,8 @@ }, "node_modules/restore-cursor": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "license": "MIT", "dependencies": { "onetime": "^7.0.0", @@ -13606,6 +15160,8 @@ }, "node_modules/restore-cursor/node_modules/onetime": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "license": "MIT", "dependencies": { "mimic-function": "^5.0.0" @@ -13619,6 +15175,8 @@ }, "node_modules/ret": { "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", "license": "MIT", "engines": { "node": ">=4" @@ -13626,12 +15184,14 @@ }, "node_modules/rfdc": { "version": "1.4.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" }, "node_modules/rollup": { "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "dev": true, - "license": "MIT", "dependencies": { "@types/estree": "1.0.8" }, @@ -13673,10 +15233,14 @@ }, "node_modules/rope-sequence": { "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "license": "MIT" }, "node_modules/router": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -13691,6 +15255,8 @@ }, "node_modules/router/node_modules/path-to-regexp": { "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -13699,6 +15265,8 @@ }, "node_modules/rxjs": { "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -13706,8 +15274,9 @@ }, "node_modules/safe-array-concat": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", @@ -13724,8 +15293,9 @@ }, "node_modules/safe-push-apply": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "dev": true, - "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" @@ -13739,8 +15309,9 @@ }, "node_modules/safe-regex-test": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -13755,10 +15326,13 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "license": "MIT" + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -13778,6 +15352,8 @@ }, "node_modules/sass/node_modules/chokidar": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", "dependencies": { @@ -13792,6 +15368,8 @@ }, "node_modules/sass/node_modules/readdirp": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", "engines": { @@ -13804,6 +15382,8 @@ }, "node_modules/saxes": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", "dependencies": { @@ -13815,11 +15395,13 @@ }, "node_modules/scheduler": { "version": "0.27.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" }, "node_modules/screenfull": { "version": "6.0.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-6.0.2.tgz", + "integrity": "sha512-AQdy8s4WhNvUZ6P8F6PB21tSPIYKniic+Ogx0AacBMjKP1GUHN2E9URxQHtCusiwxudnCKkdy4GrHXPPJSkCCw==", "engines": { "node": "^14.13.1 || >=16.0.0" }, @@ -13829,10 +15411,13 @@ }, "node_modules/secure-compare": { "version": "3.0.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==" }, "node_modules/semver": { "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -13843,8 +15428,9 @@ }, "node_modules/set-function-length": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, - "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -13859,8 +15445,9 @@ }, "node_modules/set-function-name": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, - "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -13873,8 +15460,9 @@ }, "node_modules/set-proto": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, - "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", @@ -13886,11 +15474,13 @@ }, "node_modules/setprototypeof": { "version": "1.2.0", - "license": "ISC" + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, "node_modules/shebang-command": { "version": "2.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -13900,14 +15490,16 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "engines": { "node": ">=8" } }, "node_modules/side-channel": { "version": "1.1.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -13924,7 +15516,8 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -13938,7 +15531,8 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -13954,7 +15548,8 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -13971,11 +15566,15 @@ }, "node_modules/siginfo": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/signal-exit": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "license": "ISC", "engines": { "node": ">=14" @@ -13986,6 +15585,8 @@ }, "node_modules/sigstore": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", @@ -14001,6 +15602,8 @@ }, "node_modules/simple-swizzle": { "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", "license": "MIT", "dependencies": { "is-arrayish": "^0.3.1" @@ -14008,10 +15611,14 @@ }, "node_modules/simple-swizzle/node_modules/is-arrayish": { "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, "node_modules/sirv": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "dev": true, "license": "MIT", "dependencies": { @@ -14025,6 +15632,8 @@ }, "node_modules/slash": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", "dev": true, "license": "MIT", "engines": { @@ -14033,6 +15642,8 @@ }, "node_modules/slice-ansi": { "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -14047,6 +15658,8 @@ }, "node_modules/slice-ansi/node_modules/ansi-styles": { "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -14057,6 +15670,8 @@ }, "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.3.1" @@ -14070,6 +15685,8 @@ }, "node_modules/smart-buffer": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -14078,6 +15695,8 @@ }, "node_modules/socks": { "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "license": "MIT", "dependencies": { "ip-address": "^10.1.1", @@ -14090,6 +15709,8 @@ }, "node_modules/socks-proxy-agent": { "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -14102,15 +15723,17 @@ }, "node_modules/source-map": { "version": "0.7.6", - "license": "BSD-3-Clause", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "engines": { "node": ">= 12" } }, "node_modules/source-map-explorer": { "version": "2.5.3", + "resolved": "https://registry.npmjs.org/source-map-explorer/-/source-map-explorer-2.5.3.tgz", + "integrity": "sha512-qfUGs7UHsOBE5p/lGfQdaAj/5U/GWYBw2imEpD6UQNkqElYonkow8t+HBL1qqIl3CuGZx7n8/CQo4x1HwSHhsg==", "dev": true, - "license": "Apache-2.0", "dependencies": { "btoa": "^1.2.1", "chalk": "^4.1.0", @@ -14135,8 +15758,9 @@ }, "node_modules/source-map-explorer/node_modules/glob": { "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, - "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -14154,8 +15778,9 @@ }, "node_modules/source-map-explorer/node_modules/open": { "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "dev": true, - "license": "MIT", "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -14169,8 +15794,9 @@ }, "node_modules/source-map-explorer/node_modules/rimraf": { "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "dev": true, - "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -14180,8 +15806,9 @@ }, "node_modules/source-map-explorer/node_modules/temp": { "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, - "license": "MIT", "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -14192,8 +15819,9 @@ }, "node_modules/source-map-explorer/node_modules/yargs": { "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, - "license": "MIT", "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -14209,24 +15837,27 @@ }, "node_modules/source-map-explorer/node_modules/yargs-parser": { "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, - "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/source-map-js": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, "node_modules/source-map-support": { "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, - "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -14234,18 +15865,23 @@ }, "node_modules/source-map-support/node_modules/source-map": { "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, "node_modules/spdx-exceptions": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", @@ -14254,14 +15890,19 @@ }, "node_modules/spdx-license-ids": { "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "license": "CC0-1.0" }, "node_modules/sprintf-js": { "version": "1.0.3", - "license": "BSD-3-Clause" + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, "node_modules/ssri": { "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -14272,23 +15913,30 @@ }, "node_modules/stackback": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/statuses": { "version": "2.0.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "engines": { "node": ">= 0.8" } }, "node_modules/std-env": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, "node_modules/stdin-discarder": { "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", "license": "MIT", "engines": { "node": ">=18" @@ -14299,7 +15947,8 @@ }, "node_modules/stimulus-use": { "version": "0.52.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/stimulus-use/-/stimulus-use-0.52.3.tgz", + "integrity": "sha512-stZ5dID6FUrGCR/ChWUa0FT5Z8iqkzT6lputOAb50eF+Ayg7RzJj4U/HoRlp2NV333QfvoRidru9HLbom4hZVw==", "peerDependencies": { "@hotwired/stimulus": ">= 3", "hotkeys-js": ">= 3" @@ -14307,6 +15956,8 @@ }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14319,8 +15970,9 @@ }, "node_modules/string-width": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, - "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -14332,13 +15984,15 @@ }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "node_modules/string.prototype.includes": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -14350,8 +16004,9 @@ }, "node_modules/string.prototype.matchall": { "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -14376,8 +16031,9 @@ }, "node_modules/string.prototype.repeat": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, - "license": "MIT", "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" @@ -14385,8 +16041,9 @@ }, "node_modules/string.prototype.trim": { "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", @@ -14405,8 +16062,9 @@ }, "node_modules/string.prototype.trimend": { "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", @@ -14422,8 +16080,9 @@ }, "node_modules/string.prototype.trimstart": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -14438,8 +16097,9 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -14449,14 +16109,17 @@ }, "node_modules/strip-bom": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, - "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/strip-indent": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14468,6 +16131,8 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -14479,6 +16144,8 @@ }, "node_modules/styled-components": { "version": "6.4.2", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.4.2.tgz", + "integrity": "sha512-xZBhBJsMtGqb+aKcwKgaT+BtuFums9VynX2JRvXJGTx5UfZzN12rk5r4nVdhXYvRw+hE7yiYxVrOqJZaK2+Txg==", "license": "MIT", "dependencies": { "@emotion/is-prop-valid": "1.4.0", @@ -14513,10 +16180,14 @@ }, "node_modules/stylis": { "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -14527,8 +16198,9 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -14538,22 +16210,29 @@ }, "node_modules/symbol-tree": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, "license": "MIT" }, "node_modules/tabbable": { "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", "license": "MIT" }, "node_modules/tablesorter": { "version": "2.32.0", - "license": "(MIT OR GPL-2.0)", + "resolved": "https://registry.npmjs.org/tablesorter/-/tablesorter-2.32.0.tgz", + "integrity": "sha512-tKjx6H+ZVoxND5ukDIVvF4AKEIw0ZJde/e6YlAZlUOKI0o0xD2RZLTb2tKB1hiDHkHGLHGQ7ndvd0Pnkj5Xzjw==", "dependencies": { "jquery": ">=1.2.6" } }, "node_modules/tagged-tag": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", "license": "MIT", "engines": { "node": ">=20" @@ -14564,6 +16243,8 @@ }, "node_modules/tar": { "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -14578,6 +16259,8 @@ }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -14585,14 +16268,16 @@ }, "node_modules/text-segmentation": { "version": "1.0.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", "dependencies": { "utrie": "^1.0.2" } }, "node_modules/texture-compressor": { "version": "1.0.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", + "integrity": "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==", "dependencies": { "argparse": "^1.0.10", "image-size": "^0.7.4" @@ -14603,7 +16288,8 @@ }, "node_modules/texture-compressor/node_modules/image-size": { "version": "0.7.5", - "license": "MIT", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", + "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==", "bin": { "image-size": "bin/image-size.js" }, @@ -14613,11 +16299,13 @@ }, "node_modules/ticky": { "version": "1.0.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/ticky/-/ticky-1.0.1.tgz", + "integrity": "sha512-RX35iq/D+lrsqhcPWIazM9ELkjOe30MSeoBHQHSsRwd1YuhJO5ui1K1/R0r7N3mFvbLBs33idw+eR6j+w6i/DA==" }, "node_modules/timers-ext": { "version": "0.1.7", - "license": "ISC", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", + "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", "dependencies": { "es5-ext": "~0.10.46", "next-tick": "1" @@ -14625,15 +16313,20 @@ }, "node_modules/tiny-invariant": { "version": "1.3.3", - "license": "MIT" + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, "node_modules/tinybench": { "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", "dev": true, "license": "MIT", "engines": { @@ -14642,6 +16335,8 @@ }, "node_modules/tinyglobby": { "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -14656,6 +16351,8 @@ }, "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -14666,6 +16363,8 @@ }, "node_modules/tinyrainbow": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -14674,6 +16373,8 @@ }, "node_modules/tldts": { "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", "dev": true, "license": "MIT", "dependencies": { @@ -14685,11 +16386,15 @@ }, "node_modules/tldts-core": { "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", "dev": true, "license": "MIT" }, "node_modules/tmp": { "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -14698,8 +16403,9 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, - "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -14709,25 +16415,30 @@ }, "node_modules/to-regex-range/node_modules/is-number": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.12.0" } }, "node_modules/toidentifier": { "version": "1.0.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "engines": { "node": ">=0.6" } }, "node_modules/toposort": { "version": "2.0.2", - "license": "MIT" + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" }, "node_modules/totalist": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, "license": "MIT", "engines": { @@ -14736,6 +16447,8 @@ }, "node_modules/tough-cookie": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -14747,6 +16460,8 @@ }, "node_modules/tr46": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, "license": "MIT", "dependencies": { @@ -14758,11 +16473,13 @@ }, "node_modules/ts-action": { "version": "11.0.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/ts-action/-/ts-action-11.0.0.tgz", + "integrity": "sha512-TDxvzZ5UtKsBuw0McRs/L+iASj80m60nV8EXCP680ZdIl+oRHPGnBk85sZJWXLzqQwb6yQ+CcMzPTfBr0dEaAA==" }, "node_modules/ts-action-operators": { "version": "9.1.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/ts-action-operators/-/ts-action-operators-9.1.2.tgz", + "integrity": "sha512-DZ+sJvBdOluThwCBcCdDnz0NiVeQxEwveL+cmkJGLO1ps+x/D0xwSefmOIa1S7p2nXFHH52ZPr/8VEvxZHaNgg==", "peerDependencies": { "rxjs": ">=6.0.0", "ts-action": "^11.0.0 || ^10.0.0 || ^9.0.0" @@ -14770,6 +16487,8 @@ }, "node_modules/ts-api-utils": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -14781,8 +16500,9 @@ }, "node_modules/ts-node": { "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, - "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14823,10 +16543,13 @@ }, "node_modules/tslib": { "version": "2.8.1", - "license": "0BSD" + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tuf-js": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", "license": "MIT", "dependencies": { "@tufjs/models": "4.1.0", @@ -14839,17 +16562,21 @@ }, "node_modules/turbo_power": { "version": "0.7.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/turbo_power/-/turbo_power-0.7.1.tgz", + "integrity": "sha512-xnB1Yb3xXSVcDQyiqZV8kLjbHdCmyBfQYZJeJklEnEz+jM5xl6JUNo2yy5vc/8ZPKKMzS05INvvvLxafQYJVuQ==", "peerDependencies": { "@hotwired/turbo": ">= 7.2" } }, "node_modules/type": { "version": "2.7.3", - "license": "ISC" + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" }, "node_modules/type-check": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -14861,6 +16588,8 @@ }, "node_modules/type-fest": { "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -14874,12 +16603,14 @@ }, "node_modules/type-func": { "version": "1.0.3", - "license": "MIT" + "resolved": "https://registry.npmjs.org/type-func/-/type-func-1.0.3.tgz", + "integrity": "sha512-YA90CUk+i00tWESPNRMahywXhAz+12NLJLKlOWrgHIbqaFXjdZrWstRghaibOW/IxhPjui4SmXxO/03XSGRIjA==" }, "node_modules/typed-array-buffer": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -14891,8 +16622,9 @@ }, "node_modules/typed-array-byte-length": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", @@ -14909,8 +16641,9 @@ }, "node_modules/typed-array-byte-offset": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, - "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", @@ -14929,8 +16662,9 @@ }, "node_modules/typed-array-length": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", @@ -14948,13 +16682,16 @@ }, "node_modules/typedjson": { "version": "1.8.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/typedjson/-/typedjson-1.8.0.tgz", + "integrity": "sha512-taVJVGebQDagEmVc3Cu6vVVLkWLnxqPcTrkVgbpAsI02ZDDrnHy5zvt1JVqXv4/yztBgZAX1oR07+bkiusGJLQ==", "dependencies": { "tslib": "^2.0.1" } }, "node_modules/typescript": { "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -14966,6 +16703,8 @@ }, "node_modules/typescript-eslint": { "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", "dev": true, "license": "MIT", "dependencies": { @@ -14988,8 +16727,9 @@ }, "node_modules/unbox-primitive": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", @@ -15005,6 +16745,8 @@ }, "node_modules/undici": { "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -15013,11 +16755,15 @@ }, "node_modules/undici-types": { "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, "node_modules/union": { "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", "dependencies": { "qs": "^6.4.0" }, @@ -15027,6 +16773,8 @@ }, "node_modules/universalify": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { @@ -15035,13 +16783,16 @@ }, "node_modules/unpipe": { "version": "1.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "engines": { "node": ">= 0.8" } }, "node_modules/update-browserslist-db": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -15070,6 +16821,8 @@ }, "node_modules/uri-js": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -15078,14 +16831,18 @@ }, "node_modules/urijs": { "version": "1.19.11", - "license": "MIT" + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" }, "node_modules/url-join": { "version": "2.0.5", - "license": "MIT" + "resolved": "https://registry.npmjs.org/url-join/-/url-join-2.0.5.tgz", + "integrity": "sha512-c2H1fIgpUdwFRIru9HFno5DT73Ok8hg5oOb5AT3ayIgvCRfxgs2jyt5Slw8kEB7j3QUr6yJmMPDT/odjk7jXow==" }, "node_modules/url-template": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-3.1.1.tgz", + "integrity": "sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==", "license": "BSD-3-Clause", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -15093,6 +16850,8 @@ }, "node_modules/use-callback-ref": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -15112,6 +16871,8 @@ }, "node_modules/use-sidecar": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", "license": "MIT", "dependencies": { "detect-node-es": "^1.1.0", @@ -15132,6 +16893,8 @@ }, "node_modules/use-sync-external-store": { "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -15139,7 +16902,8 @@ }, "node_modules/utrie": { "version": "1.0.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", "dependencies": { "base64-arraybuffer": "^1.0.2" } @@ -15159,11 +16923,15 @@ }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true, "license": "MIT" }, "node_modules/validate-npm-package-name": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", "license": "ISC", "engines": { "node": "^20.17.0 || >=22.9.0" @@ -15171,7 +16939,8 @@ }, "node_modules/vary": { "version": "1.1.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "engines": { "node": ">= 0.8" } @@ -15253,8 +17022,9 @@ }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -15264,6 +17034,8 @@ }, "node_modules/vitest": { "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { @@ -15352,6 +17124,8 @@ }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -15363,6 +17137,8 @@ }, "node_modules/vscode-css-languageservice": { "version": "6.3.10", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", + "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", "dev": true, "license": "MIT", "dependencies": { @@ -15374,25 +17150,35 @@ }, "node_modules/vscode-languageserver-textdocument": { "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", "dev": true, "license": "MIT" }, "node_modules/vscode-languageserver-types": { "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", "dev": true, "license": "MIT" }, "node_modules/vscode-uri": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "dev": true, "license": "MIT" }, "node_modules/w3c-keyname": { "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", "dependencies": { @@ -15404,8 +17190,9 @@ }, "node_modules/watchpack": { "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, - "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -15416,12 +17203,16 @@ }, "node_modules/weak-lru-cache": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", "dev": true, "license": "MIT", "optional": true }, "node_modules/webidl-conversions": { "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -15430,6 +17221,8 @@ }, "node_modules/whatwg-mimetype": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, "license": "MIT", "engines": { @@ -15438,6 +17231,8 @@ }, "node_modules/whatwg-url": { "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, "license": "MIT", "dependencies": { @@ -15451,7 +17246,8 @@ }, "node_modules/which": { "version": "2.0.2", - "license": "ISC", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dependencies": { "isexe": "^2.0.0" }, @@ -15464,8 +17260,9 @@ }, "node_modules/which-boxed-primitive": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, - "license": "MIT", "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", @@ -15482,8 +17279,9 @@ }, "node_modules/which-builtin-type": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "dev": true, - "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", @@ -15508,8 +17306,9 @@ }, "node_modules/which-collection": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, - "license": "MIT", "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", @@ -15525,6 +17324,8 @@ }, "node_modules/which-typed-array": { "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", "dev": true, "license": "MIT", "dependencies": { @@ -15545,6 +17346,8 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -15560,6 +17363,8 @@ }, "node_modules/word-wrap": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -15568,8 +17373,9 @@ }, "node_modules/wrap-ansi": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -15584,10 +17390,13 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "license": "ISC" + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -15608,6 +17417,8 @@ }, "node_modules/wscat": { "version": "6.1.0", + "resolved": "https://registry.npmjs.org/wscat/-/wscat-6.1.0.tgz", + "integrity": "sha512-x6gEZvITvqWslR38DoBfnMi37ZBUGsG9rTkGc/200sEfSs1JwgKLZYQeqa0vlu3bxXQV7hEHI4NF7KQmYIzB2A==", "dev": true, "license": "MIT", "dependencies": { @@ -15625,6 +17436,8 @@ }, "node_modules/wscat/node_modules/commander": { "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, "license": "MIT", "engines": { @@ -15633,6 +17446,8 @@ }, "node_modules/xml-but-prettier": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-but-prettier/-/xml-but-prettier-1.0.1.tgz", + "integrity": "sha512-C2CJaadHrZTqESlH03WOyw0oZTtoy2uEg6dSDF6YRg+9GnYNub53RRemLpnvtbHDFelxMx4LajiFsYeR6XJHgQ==", "license": "MIT", "dependencies": { "repeat-string": "^1.5.2" @@ -15640,6 +17455,8 @@ }, "node_modules/xml-name-validator": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -15648,12 +17465,15 @@ }, "node_modules/xmlchars": { "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" }, "node_modules/y-prosemirror": { "version": "1.3.7", - "license": "MIT", + "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz", + "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==", "dependencies": { "lib0": "^0.2.109" }, @@ -15675,6 +17495,8 @@ }, "node_modules/y-protocols": { "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", "license": "MIT", "dependencies": { "lib0": "^0.2.85" @@ -15693,17 +17515,21 @@ }, "node_modules/y18n": { "version": "5.0.8", - "license": "ISC", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "engines": { "node": ">=10" } }, "node_modules/yallist": { "version": "3.1.1", - "license": "ISC" + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yaml": { "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "bin": { @@ -15718,6 +17544,8 @@ }, "node_modules/yjs": { "version": "13.6.27", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", + "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", "license": "MIT", "dependencies": { "lib0": "^0.2.99" @@ -15733,16 +17561,18 @@ }, "node_modules/yn": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/yocto-queue": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, @@ -15752,6 +17582,8 @@ }, "node_modules/yoctocolors": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", "license": "MIT", "engines": { "node": ">=18" @@ -15762,6 +17594,8 @@ }, "node_modules/zod": { "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -15769,6 +17603,8 @@ }, "node_modules/zod-to-json-schema": { "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", "peerDependencies": { "zod": "^3.25.28 || ^4" @@ -15776,8 +17612,9 @@ }, "node_modules/zod-validation-error": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.0.0" }, From aa9dfd27c8529289d180cd02e5e611e858c33fb2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 4 Jul 2026 22:38:53 +0100 Subject: [PATCH 068/105] Ignore blank position params in backlogs moves A blank `position` survives params compaction and casts to nil inside `move_after`, silently moving the work package to the top of its list. Leaves `prev_id` truthiness untouched: blank means insert at the top. --- .../backlogs/work_packages/update_service.rb | 4 +++- .../work_packages/update_service_spec.rb | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/modules/backlogs/app/services/backlogs/work_packages/update_service.rb b/modules/backlogs/app/services/backlogs/work_packages/update_service.rb index 35649bb9d8bd..ebfe94768a46 100644 --- a/modules/backlogs/app/services/backlogs/work_packages/update_service.rb +++ b/modules/backlogs/app/services/backlogs/work_packages/update_service.rb @@ -40,9 +40,11 @@ def call(direction: nil, list_type: nil, list_id: nil, position: nil, prev_id: n resolve_required_attributes(direction:, list_type:, list_id:) .bind { |attrs| ::WorkPackages::UpdateService.new(user:, model: story).call(**attrs) } .on_success do |call| + # A blank prev_id is meaningful (insert at the top of the list), a + # blank position is not: it would cast to nil and move to the top too. if prev_id call.result.move_after(prev_id:) - elsif position + elsif position.present? call.result.move_after(position:) end end diff --git a/modules/backlogs/spec/services/backlogs/work_packages/update_service_spec.rb b/modules/backlogs/spec/services/backlogs/work_packages/update_service_spec.rb index 129be35249a2..22c20a314175 100644 --- a/modules/backlogs/spec/services/backlogs/work_packages/update_service_spec.rb +++ b/modules/backlogs/spec/services/backlogs/work_packages/update_service_spec.rb @@ -189,6 +189,26 @@ end end + context "with a blank position" do + it "does not reorder the work package" do + allow(story).to receive(:move_after) + + instance.call(list_type: "inbox", position: "") + + expect(story).not_to have_received(:move_after) + end + end + + context "with a blank prev_id" do + it "calls move_after so the work package moves to the top" do + allow(story).to receive(:move_after) + + instance.call(list_type: "inbox", prev_id: "") + + expect(story).to have_received(:move_after).with(prev_id: "") + end + end + context "with both prev_id and position" do it "prefers prev_id over position" do allow(story).to receive(:move_after) From 77e68f6a676502264eb4cd20898e7f914c508b90 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 4 Jul 2026 22:39:13 +0100 Subject: [PATCH 069/105] Preserve split view selection across Turbo morphs The backlogs work-package controller only re-syncs `data-selected` and `aria-current` on `turbo:visit`, so a list refresh morphing the cards stripped the open work package's highlight. --- .../src/turbo/pragmatic-dnd-morph-attributes.spec.ts | 12 ++++++++++++ frontend/src/turbo/pragmatic-dnd-morph-attributes.ts | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts b/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts index 0ae7f52cf943..e4cea4bf952b 100644 --- a/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts +++ b/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts @@ -76,6 +76,18 @@ describe('Pragmatic DnD morph attribute preservation', () => { expect(event.defaultPrevented).toBe(true); }); + it('preserves the split view selection markers during Turbo morphs', () => { + ['data-selected', 'aria-current'].forEach((attributeName) => { + const element = appendSortableRow(); + const event = beforeMorphAttributeEvent(attributeName); + + element.setAttribute(attributeName, 'true'); + element.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + }); + it('lets marker attributes morph when the mutation is not a removal', () => { const element = appendSortableRow(); const event = beforeMorphAttributeEvent('data-dragging', 'updated'); diff --git a/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts b/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts index 2600448471f8..8c61113310ea 100644 --- a/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts +++ b/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts @@ -33,6 +33,11 @@ import { sortableListsRootSelector } from '../stimulus/controllers/dynamic/sorta const preservedAttributes = new Set([ 'data-dragging', 'data-drop-target-for-element', + // Split view selection markers set by the backlogs work-package + // controller; the controller only re-syncs them on `turbo:visit`, so a + // morph would otherwise strip the highlight of the open work package. + 'data-selected', + 'aria-current', ]); let registered = false; From 1cf8a01052171836c5aef1f332afe0ee9ffc2d2c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 4 Jul 2026 22:39:29 +0100 Subject: [PATCH 070/105] Preserve drop indicator markers across morphs A list refresh morphing mid-drag stripped the app-owned landing cue while the drag stayed active, leaving only the dimmed source card. --- .../src/turbo/pragmatic-dnd-morph-attributes.spec.ts | 12 ++++++++++++ frontend/src/turbo/pragmatic-dnd-morph-attributes.ts | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts b/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts index e4cea4bf952b..f323cb2259d5 100644 --- a/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts +++ b/frontend/src/turbo/pragmatic-dnd-morph-attributes.spec.ts @@ -76,6 +76,18 @@ describe('Pragmatic DnD morph attribute preservation', () => { expect(event.defaultPrevented).toBe(true); }); + it('preserves the drop indicator markers during Turbo morphs', () => { + ['data-drop-position', 'data-drop-position-owner', 'data-drop-container'].forEach((attributeName) => { + const element = appendSortableRow(); + const event = beforeMorphAttributeEvent(attributeName); + + element.setAttribute(attributeName, 'top'); + element.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + }); + it('preserves the split view selection markers during Turbo morphs', () => { ['data-selected', 'aria-current'].forEach((attributeName) => { const element = appendSortableRow(); diff --git a/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts b/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts index 8c61113310ea..4cf3e60c0a43 100644 --- a/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts +++ b/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts @@ -33,6 +33,12 @@ import { sortableListsRootSelector } from '../stimulus/controllers/dynamic/sorta const preservedAttributes = new Set([ 'data-dragging', 'data-drop-target-for-element', + // Drop indicator markers rendered by the sortable-lists item/list + // controllers; a list refresh morphing mid-drag must not strip the + // landing cue while the drag is still active. + 'data-drop-position', + 'data-drop-position-owner', + 'data-drop-container', // Split view selection markers set by the backlogs work-package // controller; the controller only re-syncs them on `turbo:visit`, so a // morph would otherwise strip the highlight of the open work package. From 4eec77763b2b54765317f0f1d669404685e584cb Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 4 Jul 2026 22:39:36 +0100 Subject: [PATCH 071/105] Remove dead drag handle check in backlogs cards --- .../controllers/dynamic/backlogs/work-package.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.ts index 74abf21c9937..63238e30dd18 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.ts @@ -191,7 +191,6 @@ export default class WorkPackageController extends Controller imple 'a', 'button', 'clipboard-copy', - '[data-drag-handle]', ].some((selector) => target.closest(selector) !== null); } From 99cd753145810b0bd24a836fd0cd2c6195cfe272 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 5 Jul 2026 13:33:48 +0100 Subject: [PATCH 072/105] Add drop zone overlay to empty backlogs lists Adds an optional `drop_target_label` to the border box list empty state, displayed as an overlay that appears whenever a sortable item hovers the empty list, telling users that dropping will move the item to that list. Colors come from Primer tokens; transitions honor reduced motion. --- .../common/border_box_list_component.rb | 17 ++++++-- .../common/border_box_list_component.sass | 37 +++++++++++++++++ .../empty_state.html.erb | 40 +++++++++++++++++++ .../border_box_list_component/empty_state.rb | 24 +++++++++-- .../backlogs/bucket_component.html.erb | 3 +- .../backlogs/inbox_component.html.erb | 1 + .../backlogs/sprint_component.html.erb | 3 +- modules/backlogs/config/locales/en.yml | 3 ++ .../common/border_box_list_component_spec.rb | 30 ++++++++++++++ 9 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 app/components/open_project/common/border_box_list_component/empty_state.html.erb diff --git a/app/components/open_project/common/border_box_list_component.rb b/app/components/open_project/common/border_box_list_component.rb index d317402cf6ff..2e0eaf791b3a 100644 --- a/app/components/open_project/common/border_box_list_component.rb +++ b/app/components/open_project/common/border_box_list_component.rb @@ -141,12 +141,23 @@ class BorderBoxListComponent < ApplicationComponent # # @param title [String] empty-state title. # # @param description [String, nil] optional supporting text. # # @param icon [Symbol, nil] optional Primer icon. + # # @param drop_target_label [String, nil] when given, renders a + # # drop-zone overlay with this label. The overlay becomes visible + # # while a sortable item hovers the surrounding + # # `[data-drop-container]` list. # # @param system_arguments [Hash] forwarded to `Primer::Beta::Blankslate`. # # @return [ViewComponent::Slot] - # def with_empty_state(title:, description: nil, icon: nil, **system_arguments) + # def with_empty_state(title:, description: nil, icon: nil, drop_target_label: nil, **system_arguments) # end - renders_one :empty_state, ->(title:, description: nil, icon: nil, **system_arguments) { - EmptyState.new(title:, description:, icon:, interactive: interactive?, **system_arguments) + renders_one :empty_state, ->(title:, description: nil, icon: nil, drop_target_label: nil, **system_arguments) { + EmptyState.new( + title:, + description:, + icon:, + interactive: interactive?, + drop_target_label:, + **system_arguments + ) } # Optional footer row. diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index 86422ec7ed67..bb0882256156 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -194,6 +194,43 @@ content: '' bottom: calc(var(--op-backlogs-drop-gap-height) / -2) + +// Empty-state wrapper. With a drop target label, an overlay covers the +// blankslate while a sortable item hovers the list, labelling it as a drop +// zone. Hidden via visibility so it is unreachable while inactive; the +// visibility delay keeps the overlay shown until the fade-out finishes. +.op-border-box-list-empty-state + position: relative + + &--drop-overlay + position: absolute + // Bottom inset keeps the box's bottom border and rounding visible. + inset: 0 0 var(--base-size-4) 0 + border: var(--borderWidth-thin) dashed var(--borderColor-default) + z-index: 1 + + display: flex + align-items: center + justify-content: center + + background: var(--bgColor-default) + color: var(--fgColor-default) + + opacity: 0 + visibility: hidden + pointer-events: none + + @media (prefers-reduced-motion: no-preference) + transition: opacity 0.1s ease-in-out, visibility 0s linear 0.1s + +[data-drop-container] .op-border-box-list-empty-state--drop-overlay + opacity: 1 + visibility: visible + pointer-events: auto + + @media (prefers-reduced-motion: no-preference) + transition: opacity 0.1s ease-in-out, visibility 0s + // Container drop zone: the whole list outlines while a sortable item hovers it, // signalling the item will land inside this list. [data-controller~="sortable-lists--list"][data-drop-container] diff --git a/app/components/open_project/common/border_box_list_component/empty_state.html.erb b/app/components/open_project/common/border_box_list_component/empty_state.html.erb new file mode 100644 index 000000000000..8c12a014a7bf --- /dev/null +++ b/app/components/open_project/common/border_box_list_component/empty_state.html.erb @@ -0,0 +1,40 @@ +<%# -- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++# %> + +<%= render(Primer::Box.new(classes: "op-border-box-list-empty-state")) do %> + <%= render(blankslate) %> + + <% if drop_target_label %> + <%# The overlay only duplicates the drop affordance for pointer + interactions, so it is hidden from assistive technology. %> + <%= render(Primer::Box.new(aria: { hidden: true }, classes: "op-border-box-list-empty-state--drop-overlay")) do %> + <%= render(Primer::Beta::Text.new(tag: :p, font_size: 4)) { drop_target_label } %> + <% end %> + <% end %> +<% end %> diff --git a/app/components/open_project/common/border_box_list_component/empty_state.rb b/app/components/open_project/common/border_box_list_component/empty_state.rb index 225c08b0d32a..9e525b929db5 100644 --- a/app/components/open_project/common/border_box_list_component/empty_state.rb +++ b/app/components/open_project/common/border_box_list_component/empty_state.rb @@ -31,7 +31,8 @@ module OpenProject module Common class BorderBoxListComponent - # Empty-state content rendered as a Primer Blankslate. + # Empty-state content rendered as a Primer Blankslate, wrapped in an + # empty-state container that can host a drop-zone overlay. # # This component is part of {BorderBoxListComponent} and should not be # used as a standalone component. @@ -44,13 +45,24 @@ class EmptyState < ApplicationComponent # @param icon [Symbol, nil] optional Primer icon. # @param interactive [Boolean] whether empty-state updates should be # announced politely to assistive technology. + # @param drop_target_label [String, nil] when given, renders a drop-zone + # overlay with this label. The overlay becomes visible while a + # sortable item hovers the surrounding `[data-drop-container]` list. # @param system_arguments [Hash] forwarded to `Primer::Beta::Blankslate`. - def initialize(title:, description: nil, icon: nil, interactive: false, **system_arguments) + def initialize( + title:, + description: nil, + icon: nil, + interactive: false, + drop_target_label: nil, + **system_arguments + ) super() @title = title @description = description @icon = icon + @drop_target_label = drop_target_label @system_arguments = system_arguments return unless interactive @@ -62,13 +74,17 @@ def initialize(title:, description: nil, icon: nil, interactive: false, **system ) end - def call + private + + attr_reader :drop_target_label + + def blankslate blankslate = Primer::Beta::Blankslate.new(**@system_arguments) blankslate.with_heading(tag: :h4).with_content(@title) blankslate.with_description_content(@description) if @description blankslate.with_visual_icon(icon: @icon) if @icon - render(blankslate) + blankslate end end end diff --git a/modules/backlogs/app/components/backlogs/bucket_component.html.erb b/modules/backlogs/app/components/backlogs/bucket_component.html.erb index a0d95ef651b8..7d81cc7abc02 100644 --- a/modules/backlogs/app/components/backlogs/bucket_component.html.erb +++ b/modules/backlogs/app/components/backlogs/bucket_component.html.erb @@ -73,7 +73,8 @@ See COPYRIGHT and LICENSE files for more details. list.with_empty_state( title: t(".blankslate_title"), - description: t(".blankslate_description") + description: t(".blankslate_description"), + drop_target_label: t(".drop_zone_label") ) end %> diff --git a/modules/backlogs/app/components/backlogs/inbox_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_component.html.erb index a15a01b8cb99..8c02ab37a16a 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb @@ -51,6 +51,7 @@ See COPYRIGHT and LICENSE files for more details. title: t(".blankslate_title"), description: t(".blankslate_description"), icon: :"op-backlogs", + drop_target_label: t(".drop_zone_label"), spacious: true ) diff --git a/modules/backlogs/app/components/backlogs/sprint_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_component.html.erb index 829221a0cbb9..6561c3224d40 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_component.html.erb @@ -147,7 +147,8 @@ See COPYRIGHT and LICENSE files for more details. <% list.with_empty_state( title: t(".blankslate_title", name: sprint.name), - description: t(".blankslate_description") + description: t(".blankslate_description"), + drop_target_label: t(".drop_zone_label") ) %> <% end %> diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 06c87d938a75..28f82f038527 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -125,6 +125,7 @@ en: bucket_component: blankslate_title: "Backlog bucket is empty" blankslate_description: "Drag items here to add them." + drop_zone_label: "Drop here to add item to this bucket" label_actions: "Backlog bucket actions" action_menu: edit_backlog_bucket: "Edit backlog bucket" @@ -154,6 +155,7 @@ en: title: "Inbox" blankslate_title: "Backlog inbox is empty" blankslate_description: "All open work packages in this project will automatically appear here." + drop_zone_label: "Drop here to add item to Inbox" show_more: one: "Show 1 more item" other: "Show %{count} more items" @@ -184,6 +186,7 @@ en: sprint_component: blankslate_title: "%{name} is empty" blankslate_description: "No items planned yet. Drag items here to add them." + drop_zone_label: "Drop here to add item to this sprint" label_actions: "Sprint actions" label_start_sprint: "Start sprint" label_complete_sprint: "Complete sprint" diff --git a/spec/components/open_project/common/border_box_list_component_spec.rb b/spec/components/open_project/common/border_box_list_component_spec.rb index 0404a86ab637..14326b4b6b3f 100644 --- a/spec/components/open_project/common/border_box_list_component_spec.rb +++ b/spec/components/open_project/common/border_box_list_component_spec.rb @@ -678,6 +678,36 @@ def call expect(rendered).to have_alert(aria: { live: "assertive", describedby: "empty-help" }) end + + it "renders a drop-zone overlay hidden from assistive technology when a drop target label is given" do + rendered = render_inline( + described_class.new(container: "empty-drop-zone") + ) do |list| + list.with_empty_state(title: "Nothing here", drop_target_label: "Drop items here") + end + + expect(rendered).to have_css(".op-border-box-list-empty-state") do |empty_state| + expect(empty_state).to have_css(".blankslate", text: "Nothing here") + expect(empty_state).to have_css( + ".op-border-box-list-empty-state--drop-overlay", + text: "Drop items here", + aria: { hidden: true } + ) + end + end + + it "does not render a drop-zone overlay without a drop target label" do + rendered = render_inline( + described_class.new(container: "empty-no-drop-zone") + ) do |list| + list.with_empty_state(title: "Nothing here") + end + + expect(rendered).to have_css(".op-border-box-list-empty-state") do |empty_state| + expect(empty_state).to have_css(".blankslate") + expect(empty_state).to have_no_css(".op-border-box-list-empty-state--drop-overlay") + end + end end describe "footer rendering" do From 25abcd935e8268dc9540194e571e6c61f157ecbc Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 5 Jul 2026 13:42:58 +0100 Subject: [PATCH 073/105] Drop inert frame attributes from backlogs partial Turbo frame responses only ever replace the frame element's children, so the attributes duplicated from `show.html.erb` were never applied. Drops them to stop the copies drifting apart and confusing reviewers. --- .../backlogs/backlog/_backlog_list.html.erb | 16 ++---- .../spec/requests/backlogs/backlog_spec.rb | 55 ++++++++----------- 2 files changed, 30 insertions(+), 41 deletions(-) diff --git a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb index c0302f2cfcee..a45c5f0ef01b 100644 --- a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb @@ -27,16 +27,12 @@ See COPYRIGHT and LICENSE files for more details. ++# %> -<%= turbo_frame_tag "backlogs_container", - refresh: :morph, - class: "op-backlogs-page", - data: { - controller: "backlogs--list-refresh sortable-lists", - sortable_lists_accepted_type_value: "work_package", - sortable_lists_move_url_template_value: backlogs_move_url_template(@project), - sortable_lists_sortable_lists__list_outlet: "#backlogs_container [data-controller~='sortable-lists--list']", - sortable_lists_sortable_lists__item_outlet: "#backlogs_container [data-controller~='sortable-lists--item']" - } do %> +<%# + Frame responses only ever replace the frame's children; the attributes of + the turbo-frame element itself (class, refresh, data-*) are kept from the + initial render in show.html.erb and are never reconciled from here. +%> +<%= turbo_frame_tag "backlogs_container" do %>
                                                  <%= diff --git a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb index 57933834ec06..51255b60bee7 100644 --- a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb +++ b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb @@ -70,12 +70,34 @@ expect(response).to have_turbo_frame "content-bodyRight" end - it "passes all=true on the backlog turbo frame when requested" do + it "renders the sortable lists configuration on the backlogs turbo frame" do + get "/projects/#{project.identifier}/backlogs/backlog" + + expect(response).to have_http_status(:ok) + expect(response.body).to include('data-controller="backlogs--list-refresh backlogs--split-view-sync sortable-lists"') + expect(response.body).to include('data-sortable-lists-accepted-type-value="work_package"') + expect(response.body).to include( + %(data-sortable-lists-move-url-template-value="/projects/#{project.identifier}/backlogs/work_packages/{id}/move") + ) + expect(response.body).to include( + "data-sortable-lists-sortable-lists--list-outlet=" \ + "\"#backlogs_container [data-controller~='sortable-lists--list']\"" + ) + expect(response.body).to include( + "data-sortable-lists-sortable-lists--item-outlet=" \ + "\"#backlogs_container [data-controller~='sortable-lists--item']\"" + ) + end + + it "passes all=true to the frame src and move URL template when requested" do get "/projects/#{project.identifier}/backlogs/backlog", params: { all: "1" } expect(response).to have_http_status(:ok) expect(response).to have_turbo_frame "backlogs_container", src: "/projects/#{project.identifier}/backlogs/backlog?all=true" + expect(response.body).to include( + %(data-sortable-lists-move-url-template-value="/projects/#{project.identifier}/backlogs/work_packages/{id}/move?all=true") + ) end context "with a Turbo Frame request" do @@ -86,37 +108,8 @@ expect(response).to render_template("backlogs/backlog/_backlog_list") expect(response).to have_turbo_frame "backlogs_container" - expect(response.body).to include('class="op-sprint-planning-container"') - expect(response.body).to include('data-controller="backlogs--list-refresh sortable-lists"') - expect(response.body).to include('data-sortable-lists-accepted-type-value="work_package"') - expect(response.body).to include( - %(data-sortable-lists-move-url-template-value="/projects/#{project.identifier}/backlogs/work_packages/{id}/move") - ) - expect(response.body).to include( - "data-sortable-lists-sortable-lists--list-outlet=" \ - "\"#backlogs_container [data-controller~='sortable-lists--list']\"" - ) - expect(response.body).to include( - "data-sortable-lists-sortable-lists--item-outlet=" \ - "\"#backlogs_container [data-controller~='sortable-lists--item']\"" - ) - expect(response.body).to include('id="owner_backlogs_container"') - expect(response.body).to include('id="sprint_backlogs_container"') - expect(response.body.scan('data-sortable-lists-target="scrollable"').size).to eq(2) expect(response).to have_no_turbo_frame "content-bodyRight" - end - - it "passes all=true to the move URL template when requested" do - get "/projects/#{project.identifier}/backlogs/backlog", - params: { all: "1" }, - headers: { "Turbo-Frame" => "backlogs_container" } - - expect(response).to have_http_status(:ok) - move_url_template = - "/projects/#{project.identifier}/backlogs/work_packages/{id}/move?all=true" - expect(response.body).to include( - %(data-sortable-lists-move-url-template-value="#{move_url_template}") - ) + expect(response.body.scan('data-sortable-lists-target="scrollable"').size).to eq(2) end context "with no sprints available" do From fb0fb8441bc2b661a92128fb1b0e6dc6352988f9 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 5 Jul 2026 19:38:00 +0100 Subject: [PATCH 074/105] Placate Yamllint: sort Backlog component keys --- modules/backlogs/config/locales/en.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 28f82f038527..d6620b800070 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -123,13 +123,13 @@ en: details: "The backlog bucket '%{name}' will be deleted and all work packages will be moved to the backlog inbox. No work package will be deleted." bucket_component: - blankslate_title: "Backlog bucket is empty" + action_menu: + delete_backlog_bucket: "Delete backlog bucket" + edit_backlog_bucket: "Edit backlog bucket" blankslate_description: "Drag items here to add them." + blankslate_title: "Backlog bucket is empty" drop_zone_label: "Drop here to add item to this bucket" label_actions: "Backlog bucket actions" - action_menu: - edit_backlog_bucket: "Edit backlog bucket" - delete_backlog_bucket: "Delete backlog bucket" burndown_chart: show: @@ -152,13 +152,13 @@ en: button_complete_sprint: "Complete sprint" inbox_component: - title: "Inbox" - blankslate_title: "Backlog inbox is empty" blankslate_description: "All open work packages in this project will automatically appear here." - drop_zone_label: "Drop here to add item to Inbox" + blankslate_title: "Backlog inbox is empty" + drop_zone_label: "Drop here to add item to inbox" show_more: one: "Show 1 more item" other: "Show %{count} more items" + title: "Inbox" label_burndown_chart: "Burndown chart" label_is_done_status: "Status %{status_name} means done" @@ -184,17 +184,17 @@ en: show_burndown_chart: "Burndown chart" sprint_component: - blankslate_title: "%{name} is empty" + action_menu: + add_work_package: "Add work package" + edit_sprint: "Edit sprint" blankslate_description: "No items planned yet. Drag items here to add them." + blankslate_title: "%{name} is empty" drop_zone_label: "Drop here to add item to this sprint" label_actions: "Sprint actions" - label_start_sprint: "Start sprint" label_complete_sprint: "Complete sprint" + label_start_sprint: "Start sprint" start_sprint_disabled_reason_active_sprint: "Another sprint is already active." start_sprint_disabled_reason_missing_dates: "Start and finish dates are required in order to start the sprint." - action_menu: - edit_sprint: "Edit sprint" - add_work_package: "Add work package" sprint_form: goal_caption: "The sprint goal is unique to this project and is not shared with other projects also using this sprint." From f61a453dfce20ac5c4e344300cf0a1a9b50576c2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 5 Jul 2026 20:50:25 +0100 Subject: [PATCH 075/105] Hide empty-state row once sibling rows exist Drag and drop inserts the dropped row next to the empty-state row, which only disappears with the server re-render. Hiding it via CSS as soon as it stops being the list's only child avoids the flicker of both showing between drop and re-render. --- .../open_project/common/border_box_list_component.sass | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index bb0882256156..9dfd69386551 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -195,6 +195,12 @@ bottom: calc(var(--op-backlogs-drop-gap-height) / -2) +// The empty-state row only makes sense while it is alone in the list. Hiding +// it as soon as a sibling row appears (e.g. dropped by drag and drop) avoids +// a flicker between the drop and the server render removing it. +.op-border-box-list > .Box-list > .Box-row[data-empty-list-item]:not(:only-child) + display: none + // Empty-state wrapper. With a drop target label, an overlay covers the // blankslate while a sortable item hovers the list, labelling it as a drop // zone. Hidden via visibility so it is unreachable while inactive; the From 1731e92bf4c9d355a3c76e58a926efe18c60bd07 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 5 Jul 2026 21:01:27 +0100 Subject: [PATCH 076/105] Consolidate border box list component styles Replaces the `[data-controller~="sortable-lists--list"]` selectors with `.op-border-box-list` so the styles no longer hook into Stimulus internals, and gathers all component-rooted rules under one block. Compiled selectors stay identical apart from that swap. --- .../common/border_box_list_component.sass | 87 +++++++++---------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index 9dfd69386551..680591ada270 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -22,35 +22,6 @@ margin-bottom: calc(var(--borderWidth-thin) * -1) border-bottom: var(--borderWidth-thin) solid $color -.op-border-box-list_transparent - > .Box-header - background-color: transparent - border-bottom: none - padding-bottom: var(--stack-padding-condensed) - - // A transparent (not absent) border keeps the first row's height stable - // when a state recolors it. These recolors repeat the state rules from - // further down because this selector outweighs them. - > ul > .Box-row:first-of-type - border-top-color: transparent - - &:has(> .Box-card[data-selected]) - border-top-color: var(--box-list-item-selected-border-color) - - &:has(> .Box-card[aria-current="true"]) - border-top-color: var(--box-list-item-pressed-border-color) - -// Upstream rounds the outer row corners for boxes whose rows form the box -// edge. With a header or footer between the row and the box edge, a row -// background (hover, focus, selected) would show rounded corners mid-box. -.op-border-box-list:has(> .Box-header) > ul > .Box-row:first-of-type - border-top-left-radius: 0 - border-top-right-radius: 0 - -.op-border-box-list:has(> .Box-footer) > ul > .Box-row:last-of-type - border-bottom-left-radius: 0 - border-bottom-right-radius: 0 - .op-border-box-list --op-border-box-list-header-row-gap: calc(var(--stack-gap-condensed) / 2) @@ -81,12 +52,54 @@ .Box-title font-size: var(--text-title-size-medium) + &_transparent + > .Box-header + background-color: transparent + border-bottom: none + padding-bottom: var(--stack-padding-condensed) + + // A transparent (not absent) border keeps the first row's height stable + // when a state recolors it. These recolors repeat the state rules from + // further down because this selector outweighs them. + > ul > .Box-row:first-of-type + border-top-color: transparent + + &:has(> .Box-card[data-selected]) + border-top-color: var(--box-list-item-selected-border-color) + + &:has(> .Box-card[aria-current="true"]) + border-top-color: var(--box-list-item-pressed-border-color) + &.op-border-box-list_transparent &.op-border-box-list_header-padding-default, &.op-border-box-list_header-padding-spacious > .Box-header padding-bottom: var(--stack-padding-condensed) + // Upstream rounds the outer row corners for boxes whose rows form the box + // edge. With a header or footer between the row and the box edge, a row + // background (hover, focus, selected) would show rounded corners mid-box. + &:has(> .Box-header) > ul > .Box-row:first-of-type + border-top-left-radius: 0 + border-top-right-radius: 0 + + &:has(> .Box-footer) > ul > .Box-row:last-of-type + border-bottom-left-radius: 0 + border-bottom-right-radius: 0 + + // Container drop zone: the whole list outlines while a sortable item hovers + // it, signalling the item will land inside this list. + &[data-drop-container] + outline: var(--borderWidth-thick) solid var(--fgColor-accent) + outline-offset: calc(var(--borderWidth-thick) * -1) + border-radius: var(--borderRadius-medium) + + // The empty-state row only makes sense while it is alone in the list. Hiding + // it as soon as a sibling row appears (e.g. dropped by drag and drop) avoids + // a flicker between the drop and the server render removing it. + > .Box-list > .Box-row[data-empty-list-item]:not(:only-child) + display: none + .op-border-box-list-header display: grid grid-template-columns: minmax(0, 1fr) minmax(5rem, max-content) auto @@ -194,13 +207,6 @@ content: '' bottom: calc(var(--op-backlogs-drop-gap-height) / -2) - -// The empty-state row only makes sense while it is alone in the list. Hiding -// it as soon as a sibling row appears (e.g. dropped by drag and drop) avoids -// a flicker between the drop and the server render removing it. -.op-border-box-list > .Box-list > .Box-row[data-empty-list-item]:not(:only-child) - display: none - // Empty-state wrapper. With a drop target label, an overlay covers the // blankslate while a sortable item hovers the list, labelling it as a drop // zone. Hidden via visibility so it is unreachable while inactive; the @@ -229,7 +235,7 @@ @media (prefers-reduced-motion: no-preference) transition: opacity 0.1s ease-in-out, visibility 0s linear 0.1s -[data-drop-container] .op-border-box-list-empty-state--drop-overlay +.op-border-box-list[data-drop-container] .op-border-box-list-empty-state--drop-overlay opacity: 1 visibility: visible pointer-events: auto @@ -237,13 +243,6 @@ @media (prefers-reduced-motion: no-preference) transition: opacity 0.1s ease-in-out, visibility 0s -// Container drop zone: the whole list outlines while a sortable item hovers it, -// signalling the item will land inside this list. -[data-controller~="sortable-lists--list"][data-drop-container] - outline: var(--borderWidth-thick) solid var(--fgColor-accent) - outline-offset: calc(var(--borderWidth-thick) * -1) - border-radius: var(--borderRadius-medium) - // Reapplies the padding removed from the containing .Box-row, matching the // .Box-row padding of each Box density variant upstream. .Box-card From 688a6b49c924175e37d89986a7c4bf048a31a3a2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 12:00:19 +0100 Subject: [PATCH 077/105] Suppress native drags on work-package card links Card links and the avatar image are natively draggable and pre-empt the Pragmatic card drag; mark them `draggable="false"`. --- .../common/work_package_card_component.html.erb | 5 +++-- .../components/principal/principal-renderer.service.ts | 3 +++ .../common/work_package_card_component_spec.rb | 8 ++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/components/open_project/common/work_package_card_component.html.erb b/app/components/open_project/common/work_package_card_component.html.erb index 91dabea0ee3a..01f2aa9db557 100644 --- a/app/components/open_project/common/work_package_card_component.html.erb +++ b/app/components/open_project/common/work_package_card_component.html.erb @@ -78,7 +78,7 @@ See COPYRIGHT and LICENSE files for more details. <% grid.with_area(:subject) do %> <% if link_subject %> - <%= render(Primer::Beta::Link.new(href: work_package_path(work_package), font_weight: :semibold)) { work_package.subject } %> + <%= render(Primer::Beta::Link.new(href: work_package_path(work_package), font_weight: :semibold, draggable: false)) { work_package.subject } %> <% else %> <%= render(Primer::Beta::Text.new(font_weight: :semibold)) { work_package.subject } %> <% end %> @@ -96,7 +96,8 @@ See COPYRIGHT and LICENSE files for more details. Primer::Beta::Link.new( href: work_package_path(work_package.parent), underline: false, - color: :default + color: :default, + draggable: false ) ) do %> <%= work_package.parent.subject %> diff --git a/frontend/src/app/shared/components/principal/principal-renderer.service.ts b/frontend/src/app/shared/components/principal/principal-renderer.service.ts index 6898e24dcbf3..7a9e406d986a 100644 --- a/frontend/src/app/shared/components/principal/principal-renderer.service.ts +++ b/frontend/src/app/shared/components/principal/principal-renderer.service.ts @@ -186,6 +186,9 @@ export class PrincipalRendererService { image.src = url; image.title = principal.name; image.alt = options.imageAltText ?? principal.name; + // Avatars are never meaningfully draggable; suppressing the browser's native + // image drag stops it from pre-empting Pragmatic DnD card reordering. + image.draggable = false; image.onload = () => { fallback.replaceWith(image); (fallback as unknown) = undefined; diff --git a/spec/components/open_project/common/work_package_card_component_spec.rb b/spec/components/open_project/common/work_package_card_component_spec.rb index f9ebc833a94d..9ebf0ba7ccd0 100644 --- a/spec/components/open_project/common/work_package_card_component_spec.rb +++ b/spec/components/open_project/common/work_package_card_component_spec.rb @@ -72,6 +72,10 @@ expect(rendered_component).to have_text("Card subject") end + it "marks the subject link as non-draggable so it cannot start a native drag" do + expect(rendered_component).to have_element :a, draggable: "false", text: "Card subject" + end + it "does not render story points by default" do expect(rendered_component).to have_no_text("5 points", normalize_ws: true) end @@ -159,6 +163,10 @@ expect(rendered_component).to have_link("Parent subject", href: work_package_path(parent)) end + it "marks the parent link as non-draggable so it cannot start a native drag" do + expect(rendered_component).to have_element :a, draggable: "false", text: "Parent subject" + end + it "does not render Undisclosed" do expect(rendered_component).to have_no_text("Undisclosed") end From 980c9d42d11a669c5962480535eb506996012b63 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 12:00:19 +0100 Subject: [PATCH 078/105] Flag PDD-internal morph marker attribute `data-drop-target-for-element` is a Pragmatic DnD implementation detail; note it in-code to re-verify on upgrades. --- frontend/src/turbo/pragmatic-dnd-morph-attributes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts b/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts index 4cf3e60c0a43..bba8376c79b8 100644 --- a/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts +++ b/frontend/src/turbo/pragmatic-dnd-morph-attributes.ts @@ -32,6 +32,7 @@ import { sortableListsRootSelector } from '../stimulus/controllers/dynamic/sorta // so preservation is scoped to elements inside a sortable-lists root. const preservedAttributes = new Set([ 'data-dragging', + // Internal Pragmatic DnD marker (not a documented public API); re-verify on PDD upgrades. 'data-drop-target-for-element', // Drop indicator markers rendered by the sortable-lists item/list // controllers; a list refresh morphing mid-drag must not strip the From 4f073c271bf0e22708c87a4c6e2d00a49291d386 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 13:01:32 +0100 Subject: [PATCH 079/105] Extract sortable-lists scrollable controller Move Backlogs drag auto-scroll out of the sortable-lists root into a dedicated outlet-connected `sortable-lists--scrollable` child, mirroring the list and item controllers. The root drops to monitor and drop orchestration, and scroll containers wire in through an outlet instead of being descendant targets. No behaviour change; axis and speed keep their `vertical`/`standard` defaults. --- .../dynamic/sortable-lists.controller.spec.ts | 82 ++--------- .../dynamic/sortable-lists.controller.ts | 41 +----- .../scrollable.controller.spec.ts | 129 ++++++++++++++++++ .../sortable-lists/scrollable.controller.ts | 92 +++++++++++++ .../backlogs/backlog/_backlog_list.html.erb | 4 +- .../app/views/backlogs/backlog/show.html.erb | 3 +- .../spec/requests/backlogs/backlog_spec.rb | 6 +- 7 files changed, 248 insertions(+), 109 deletions(-) create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.ts diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 3027c4935aec..cbdbebe5ed88 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -28,7 +28,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import type { autoScrollForElements as autoScrollForElementsFn } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import type { monitorForElements as monitorForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { waitFor } from '@testing-library/dom'; import { type Mock } from 'vitest'; @@ -43,7 +42,6 @@ describe('Sortable lists controller', () => { const flushPromises = () => new Promise((resolve) => setTimeout(resolve)); let monitorForElements:typeof monitorForElementsFn; - let autoScrollForElements:typeof autoScrollForElementsFn; let SortableListsController:typeof SortableListsControllerType; let sortableItemData:typeof sortableItemDataFn; let sortableListData:typeof sortableListDataFn; @@ -83,7 +81,6 @@ describe('Sortable lists controller', () => { })); ({ monitorForElements } = await import('@atlaskit/pragmatic-drag-and-drop/element/adapter')); - ({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element')); ({ default: SortableListsController } = await import('./sortable-lists.controller')); ({ sortableItemData, sortableListData } = await import('./sortable-lists/drag-and-drop')); }); @@ -123,26 +120,21 @@ describe('Sortable lists controller', () => { ${moveUrlTemplate ? `data-sortable-lists-move-url-template-value="${moveUrlTemplate}"` : ''} data-sortable-lists-sortable-lists--list-outlet="#sortable-root [data-controller~='sortable-lists--list']" data-sortable-lists-sortable-lists--item-outlet="#sortable-root [data-controller~='sortable-lists--item']" + data-sortable-lists-sortable-lists--scrollable-outlet="#sortable-root [data-controller~='sortable-lists--scrollable']" >
                                                      +
                                                      `; const [sourceList, targetList] = Array.from(fixture.querySelectorAll('[data-controller~="sortable-lists--list"]')); const root = fixture.querySelector('#sortable-root')!; + const scrollable = fixture.querySelector('[data-controller~="sortable-lists--scrollable"]')!; sourceList.append(itemRow('1'), itemRow('2'), itemRow('3')); targetList.append(itemRow('4'), itemRow('5')); - return { root, sourceList, targetList, firstSourceItem: sourceList.querySelector('[data-sortable-lists--item-id-value="1"]')! }; - } - - function renderScrollableFixture(values = '') { - fixture.innerHTML = ` -
                                                      -
                                                      -
                                                      - `; - - return fixture.querySelector('[data-sortable-lists-target="scrollable"]')!; + return { + root, sourceList, targetList, scrollable, firstSourceItem: sourceList.querySelector('[data-sortable-lists--item-id-value="1"]')!, + }; } async function dropCurrentItemOnList(sourceElement:HTMLElement, list:HTMLElement) { @@ -221,6 +213,7 @@ describe('Sortable lists controller', () => { 'sortable-lists': SortableListsController, 'sortable-lists--list': (await import('./sortable-lists/list.controller')).default, 'sortable-lists--item': (await import('./sortable-lists/item.controller')).default, + 'sortable-lists--scrollable': (await import('./sortable-lists/scrollable.controller')).default, }, }); fixture = ctx.container; @@ -540,61 +533,10 @@ describe('Sortable lists controller', () => { window.removeEventListener('op:toasters:add', onToast); }); - it('registers scrollable targets for vertical sortable list auto-scrolling', async () => { - const scrollable = renderScrollableFixture(); - const root = fixture.querySelector('[data-controller~="sortable-lists"]')!; - - await ctx.nextFrame(); - - expect(autoScrollForElements).toHaveBeenCalledWith(expect.objectContaining({ - element: scrollable, - })); - - const options = vi.mocked(autoScrollForElements).mock.lastCall?.[0]; - - expect(options?.canScroll?.({ - element: scrollable, - input: input(), - source: sourcePayload(itemRow('1'), sortableItemData({ itemId: '1', type: 'work_package', rootElement: root })), - })).toBe(true); - expect(options?.canScroll?.({ - element: scrollable, - input: input(), - source: sourcePayload(document.createElement('div'), { type: 'unrelated' }), - })).toBe(false); - expect(options?.canScroll?.({ - element: scrollable, - input: input(), - source: sourcePayload(itemRow('1'), sortableItemData({ itemId: '1', type: 'work_package', rootElement: document.createElement('div') })), - })).toBe(false); - expect(options?.getAllowedAxis?.({ - element: scrollable, - input: input(), - source: sourcePayload(itemRow('1')), - })).toEqual('vertical'); - expect(options?.getConfiguration?.({ - element: scrollable, - input: input(), - source: sourcePayload(itemRow('1')), - })).toEqual({ maxScrollSpeed: 'standard' }); - }); - - it('cleans up scrollable target auto-scrolling on disconnect', async () => { - const scrollableCleanup = vi.fn(); - vi.mocked(autoScrollForElements).mockReturnValue(scrollableCleanup); - - renderScrollableFixture(); - - await ctx.nextFrame(); - - fixture.innerHTML = ''; - await ctx.nextFrame(); - - expect(scrollableCleanup).toHaveBeenCalledOnce(); - }); - - it('hands the root reference to connected list and item controllers', async () => { - const { root, sourceList, firstSourceItem } = renderFixture({ acceptedType: 'work_package' }); + it('hands the root reference to connected list, item, and scrollable controllers', async () => { + const { + root, sourceList, scrollable, firstSourceItem, + } = renderFixture({ acceptedType: 'work_package' }); await ctx.nextFrame(); // Outlet connections happen after the controller frame; a second frame // ensures the hand-over callbacks have fired. @@ -602,9 +544,11 @@ describe('Sortable lists controller', () => { const listController = ctx.application.getControllerForElementAndIdentifier(sourceList, 'sortable-lists--list') as unknown as { ['root']?:unknown }; const itemController = ctx.application.getControllerForElementAndIdentifier(firstSourceItem, 'sortable-lists--item') as unknown as { ['root']?:unknown }; + const scrollableController = ctx.application.getControllerForElementAndIdentifier(scrollable, 'sortable-lists--scrollable') as unknown as { ['root']?:unknown }; const rootController = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists'); expect(listController.root).toBe(rootController); expect(itemController.root).toBe(rootController); + expect(scrollableController.root).toBe(rootController); }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 27b263cf4262..342539459656 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -26,7 +26,6 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import { monitorForElements, type ElementEventPayloadMap, @@ -53,25 +52,16 @@ import { type CleanupFn = () => void; type ElementDropPayload = ElementEventPayloadMap['onDrop']; -type AutoScrollAllowedAxis = 'vertical'|'horizontal'|'all'; -type AutoScrollMaxScrollSpeed = 'standard'|'fast'; type MoveResult = { ok:true }|{ ok:false; showToast:boolean }; -const allowedAxes = new Set(['vertical', 'horizontal', 'all']); -const maxScrollSpeeds = new Set(['standard', 'fast']); - export default class SortableListsController extends Controller implements SortableListsRoot { - static targets = ['scrollable']; - static outlets = ['sortable-lists--list', 'sortable-lists--item']; + static outlets = ['sortable-lists--list', 'sortable-lists--item', 'sortable-lists--scrollable']; static values = { acceptedType: String, moveUrlTemplate: String, - allowedAxis: { type: String, default: 'vertical' }, - maxScrollSpeed: { type: String, default: 'standard' }, }; - declare readonly scrollableTargets:HTMLElement[]; declare readonly sortableListsListOutlets:import('./sortable-lists/list.controller').default[]; declare readonly sortableListsItemOutlets:RootAwareChild[]; @@ -79,11 +69,8 @@ export default class SortableListsController extends Controller imp declare readonly hasAcceptedTypeValue:boolean; declare readonly moveUrlTemplateValue:string; declare readonly hasMoveUrlTemplateValue:boolean; - declare readonly allowedAxisValue:string; - declare readonly maxScrollSpeedValue:string; private monitorCleanupFn?:CleanupFn; - private scrollableCleanupFns = new Map(); connect():void { this.monitorCleanupFn = monitorForElements({ @@ -99,8 +86,6 @@ export default class SortableListsController extends Controller imp disconnect():void { this.monitorCleanupFn?.(); this.monitorCleanupFn = undefined; - this.scrollableCleanupFns.forEach((cleanup) => cleanup()); - this.scrollableCleanupFns.clear(); } sortableListsListOutletConnected(list:RootAwareChild):void { @@ -119,24 +104,12 @@ export default class SortableListsController extends Controller imp item.disconnectRoot(); } - scrollableTargetConnected(element:HTMLElement):void { - const cleanup = autoScrollForElements({ - element, - canScroll: ({ source }) => isSortableItemData(source.data) && source.data.rootElement === this.element, - getAllowedAxis: () => this.allowedAxis, - getConfiguration: () => ({ maxScrollSpeed: this.maxScrollSpeed }), - }); - - this.scrollableCleanupFns.set(element, cleanup); + sortableListsScrollableOutletConnected(scrollable:RootAwareChild):void { + scrollable.connectRoot(this); } - scrollableTargetDisconnected(element:HTMLElement):void { - this.scrollableCleanupFns.get(element)?.(); - this.scrollableCleanupFns.delete(element); - } - - private get allowedAxis():AutoScrollAllowedAxis { - return allowedAxes.has(this.allowedAxisValue) ? this.allowedAxisValue as AutoScrollAllowedAxis : 'vertical'; + sortableListsScrollableOutletDisconnected(scrollable:RootAwareChild):void { + scrollable.disconnectRoot(); } get acceptedType():string|null { @@ -145,10 +118,6 @@ export default class SortableListsController extends Controller imp return this.hasAcceptedTypeValue ? this.acceptedTypeValue : null; } - private get maxScrollSpeed():AutoScrollMaxScrollSpeed { - return maxScrollSpeeds.has(this.maxScrollSpeedValue) ? this.maxScrollSpeedValue as AutoScrollMaxScrollSpeed : 'standard'; - } - get moving():boolean { return this.element.hasAttribute(sortableListsMovingAttribute); } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts new file mode 100644 index 000000000000..54e72b79000b --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts @@ -0,0 +1,129 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { type autoScrollForElements as autoScrollForElementsFn } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; +import type ScrollableControllerType from './scrollable.controller'; +import type { sortableItemData as sortableItemDataFn, SortableListsRoot } from './drag-and-drop'; + +vi.mock('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element', () => ({ + autoScrollForElements: vi.fn(() => vi.fn()), +})); + +describe('Sortable lists scrollable controller', () => { + let autoScrollForElements:typeof autoScrollForElementsFn; + let ScrollableController:typeof ScrollableControllerType; + let sortableItemData:typeof sortableItemDataFn; + let ctx:StimulusTestContext; + + beforeAll(async () => { + ({ autoScrollForElements } = await import('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element')); + ({ default: ScrollableController } = await import('./scrollable.controller')); + ({ sortableItemData } = await import('./drag-and-drop')); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + ctx = await setupStimulusTest({ + controllers: { 'sortable-lists--scrollable': ScrollableController }, + }); + }); + + afterEach(() => ctx.dispose()); + + const input = () => ({ clientX: 0, clientY: 0 }) as never; + + async function mount(values = '') { + ctx.container.innerHTML = `
                                                      `; + await ctx.nextFrame(); + const element = ctx.container.querySelector('[data-controller~="sortable-lists--scrollable"]')!; + const controller = ctx.getController('sortable-lists--scrollable', element); + return { element, controller }; + } + + function stubRoot(element:HTMLElement):SortableListsRoot { + return { element, moving: false, acceptedType: 'work_package' }; + } + + function scrollArgs(element:HTMLElement, data:Record) { + return { element, input: input(), source: { data, element, dragHandle: null } }; + } + + it('registers auto-scroll on its own element', async () => { + const { element } = await mount(); + expect(autoScrollForElements).toHaveBeenCalledWith(expect.objectContaining({ element })); + }); + + it('refuses to scroll before a root is connected', async () => { + const { element } = await mount(); + const options = vi.mocked(autoScrollForElements).mock.lastCall?.[0]; + expect(options?.canScroll?.(scrollArgs(element, sortableItemData({ itemId: '1', type: 'work_package', rootElement: element })))).toBe(false); + }); + + it('scrolls only for sortable items owned by the connected root', async () => { + const { element, controller } = await mount(); + const root = document.createElement('div'); + controller.connectRoot(stubRoot(root)); + const options = vi.mocked(autoScrollForElements).mock.lastCall?.[0]; + + expect(options?.canScroll?.(scrollArgs(element, sortableItemData({ itemId: '1', type: 'work_package', rootElement: root })))).toBe(true); + expect(options?.canScroll?.(scrollArgs(element, sortableItemData({ itemId: '1', type: 'work_package', rootElement: document.createElement('div') })))).toBe(false); + expect(options?.canScroll?.(scrollArgs(element, { type: 'unrelated' }))).toBe(false); + }); + + it('exposes default vertical axis and standard speed', async () => { + const { element } = await mount(); + const options = vi.mocked(autoScrollForElements).mock.lastCall?.[0]; + expect(options?.getAllowedAxis?.(scrollArgs(element, { type: 'x' }))).toEqual('vertical'); + expect(options?.getConfiguration?.(scrollArgs(element, { type: 'x' }))).toEqual({ maxScrollSpeed: 'standard' }); + }); + + it('honours valid overrides and falls back on invalid ones', async () => { + const valid = await mount('data-sortable-lists--scrollable-allowed-axis-value="horizontal" data-sortable-lists--scrollable-max-scroll-speed-value="fast"'); + let options = vi.mocked(autoScrollForElements).mock.lastCall?.[0]; + expect(options?.getAllowedAxis?.(scrollArgs(valid.element, { type: 'x' }))).toEqual('horizontal'); + expect(options?.getConfiguration?.(scrollArgs(valid.element, { type: 'x' }))).toEqual({ maxScrollSpeed: 'fast' }); + + ctx.container.innerHTML = ''; + await ctx.nextFrame(); + + const invalid = await mount('data-sortable-lists--scrollable-allowed-axis-value="sideways" data-sortable-lists--scrollable-max-scroll-speed-value="warp"'); + options = vi.mocked(autoScrollForElements).mock.lastCall?.[0]; + expect(options?.getAllowedAxis?.(scrollArgs(invalid.element, { type: 'x' }))).toEqual('vertical'); + expect(options?.getConfiguration?.(scrollArgs(invalid.element, { type: 'x' }))).toEqual({ maxScrollSpeed: 'standard' }); + }); + + it('runs the auto-scroll cleanup on disconnect', async () => { + const cleanup = vi.fn(); + vi.mocked(autoScrollForElements).mockReturnValue(cleanup); + await mount(); + ctx.container.innerHTML = ''; + await ctx.nextFrame(); + expect(cleanup).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.ts new file mode 100644 index 000000000000..c722ef6fd795 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.ts @@ -0,0 +1,92 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import { Controller } from '@hotwired/stimulus'; +import { + isSortableItemData, + type RootAwareChild, + type SortableListsRoot, +} from './drag-and-drop'; + +type CleanupFn = () => void; +type AutoScrollAllowedAxis = 'vertical'|'horizontal'|'all'; +type AutoScrollMaxScrollSpeed = 'standard'|'fast'; + +const allowedAxes = new Set(['vertical', 'horizontal', 'all']); +const maxScrollSpeeds = new Set(['standard', 'fast']); + +export default class ScrollableController extends Controller implements RootAwareChild { + static values = { + allowedAxis: { type: String, default: 'vertical' }, + maxScrollSpeed: { type: String, default: 'standard' }, + }; + + declare readonly allowedAxisValue:string; + declare readonly maxScrollSpeedValue:string; + + private cleanupFn?:CleanupFn; + private root?:SortableListsRoot; + + connect():void { + this.cleanupFn = autoScrollForElements({ + element: this.element, + canScroll: ({ source }) => { + const { root } = this; + return root != null + && isSortableItemData(source.data) + && source.data.rootElement === root.element; + }, + getAllowedAxis: () => this.allowedAxis, + getConfiguration: () => ({ maxScrollSpeed: this.maxScrollSpeed }), + }); + } + + disconnect():void { + this.cleanupFn?.(); + this.cleanupFn = undefined; + this.disconnectRoot(); + } + + // Called by the root controller's outlet-connected callback. + connectRoot(root:SortableListsRoot):void { + this.root = root; + } + + disconnectRoot():void { + this.root = undefined; + } + + private get allowedAxis():AutoScrollAllowedAxis { + return allowedAxes.has(this.allowedAxisValue) ? this.allowedAxisValue as AutoScrollAllowedAxis : 'vertical'; + } + + private get maxScrollSpeed():AutoScrollMaxScrollSpeed { + return maxScrollSpeeds.has(this.maxScrollSpeedValue) ? this.maxScrollSpeedValue as AutoScrollMaxScrollSpeed : 'standard'; + } +} diff --git a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb index a45c5f0ef01b..07880446f84c 100644 --- a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb @@ -34,7 +34,7 @@ See COPYRIGHT and LICENSE files for more details. %> <%= turbo_frame_tag "backlogs_container" do %>
                                                      -
                                                      +
                                                      <%= render Backlogs::BacklogComponent.new( buckets: @backlog_buckets, @@ -43,7 +43,7 @@ See COPYRIGHT and LICENSE files for more details. ) %>
                                                      -
                                                      +
                                                      <%= render Backlogs::SprintsComponent.new( sprints: @sprints, diff --git a/modules/backlogs/app/views/backlogs/backlog/show.html.erb b/modules/backlogs/app/views/backlogs/backlog/show.html.erb index 2f3d31b5246a..43e2b8d55f23 100644 --- a/modules/backlogs/app/views/backlogs/backlog/show.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/show.html.erb @@ -53,7 +53,8 @@ See COPYRIGHT and LICENSE files for more details. sortable_lists_accepted_type_value: "work_package", sortable_lists_move_url_template_value: backlogs_move_url_template(@project), sortable_lists_sortable_lists__list_outlet: "#backlogs_container [data-controller~='sortable-lists--list']", - sortable_lists_sortable_lists__item_outlet: "#backlogs_container [data-controller~='sortable-lists--item']" + sortable_lists_sortable_lists__item_outlet: "#backlogs_container [data-controller~='sortable-lists--item']", + sortable_lists_sortable_lists__scrollable_outlet: "#backlogs_container [data-controller~='sortable-lists--scrollable']" } %> <% end %> diff --git a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb index 51255b60bee7..e68c0fde2ddf 100644 --- a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb +++ b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb @@ -87,6 +87,10 @@ "data-sortable-lists-sortable-lists--item-outlet=" \ "\"#backlogs_container [data-controller~='sortable-lists--item']\"" ) + expect(response.body).to include( + "data-sortable-lists-sortable-lists--scrollable-outlet=" \ + "\"#backlogs_container [data-controller~='sortable-lists--scrollable']\"" + ) end it "passes all=true to the frame src and move URL template when requested" do @@ -109,7 +113,7 @@ expect(response).to have_turbo_frame "backlogs_container" expect(response).to have_no_turbo_frame "content-bodyRight" - expect(response.body.scan('data-sortable-lists-target="scrollable"').size).to eq(2) + expect(response.body.scan('data-controller="sortable-lists--scrollable"').size).to eq(2) end context "with no sprints available" do From c2d3fa92a2abc44835ae39a97fcbe03ac645e508 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 14:26:50 +0100 Subject: [PATCH 080/105] Use Backlogs target list types Derives the bucket, sprint, and inbox wire-format list types from `Backlogs::Target` instead of hand-spelling them across the six components, keeping the `(list_type, list_id)` contract in one place. The move dialogs read the value into a local before the Primer form block, which is instance-exec'd against the form object and so can not resolve the component method directly. Updates the component specs to assert the shared target values. --- .../app/components/backlogs/bucket_component.html.erb | 2 +- .../backlogs/app/components/backlogs/bucket_component.rb | 4 ++++ .../app/components/backlogs/inbox_component.html.erb | 2 +- modules/backlogs/app/components/backlogs/inbox_component.rb | 4 ++++ .../backlogs/move_to_bucket_dialog_component.html.erb | 3 ++- .../components/backlogs/move_to_bucket_dialog_component.rb | 6 ++++++ .../backlogs/move_to_sprint_dialog_component.html.erb | 3 ++- .../components/backlogs/move_to_sprint_dialog_component.rb | 6 ++++++ .../app/components/backlogs/sprint_component.html.erb | 2 +- .../backlogs/app/components/backlogs/sprint_component.rb | 4 ++++ .../backlogs/work_package_card_menu_component.html.erb | 2 +- .../components/backlogs/work_package_card_menu_component.rb | 4 ++++ .../spec/components/backlogs/bucket_component_spec.rb | 4 +++- .../spec/components/backlogs/inbox_component_spec.rb | 4 +++- .../backlogs/move_to_bucket_dialog_component_spec.rb | 5 ++++- .../backlogs/move_to_sprint_dialog_component_spec.rb | 5 ++++- .../spec/components/backlogs/sprint_component_spec.rb | 4 +++- .../backlogs/work_package_card_menu_component_spec.rb | 2 +- 18 files changed, 54 insertions(+), 12 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/bucket_component.html.erb b/modules/backlogs/app/components/backlogs/bucket_component.html.erb index 7d81cc7abc02..7bdac49ec566 100644 --- a/modules/backlogs/app/components/backlogs/bucket_component.html.erb +++ b/modules/backlogs/app/components/backlogs/bucket_component.html.erb @@ -35,7 +35,7 @@ See COPYRIGHT and LICENSE files for more details. project:, container: backlog_bucket, drag_and_drop: { - list_type: "backlog_bucket", + list_type:, list_id: backlog_bucket.id, drop_position: "start" }, diff --git a/modules/backlogs/app/components/backlogs/bucket_component.rb b/modules/backlogs/app/components/backlogs/bucket_component.rb index 579d5c6ce231..a2e9a803a48e 100644 --- a/modules/backlogs/app/components/backlogs/bucket_component.rb +++ b/modules/backlogs/app/components/backlogs/bucket_component.rb @@ -52,6 +52,10 @@ def wrapper_uniq_by private + def list_type + Backlogs::Target::BucketId.new(backlog_bucket.id).list_type + end + def show_menu? backlog_bucket.persisted? && current_user.allowed_in_project?(:create_sprints, project) end diff --git a/modules/backlogs/app/components/backlogs/inbox_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_component.html.erb index 8c02ab37a16a..4697a024be53 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb @@ -40,7 +40,7 @@ See COPYRIGHT and LICENSE files for more details. test_selector: "backlog-inbox", data: { controller: "sortable-lists--list", - sortable_lists__list_type_value: "inbox", + sortable_lists__list_type_value: list_type, sortable_lists__list_drop_position_value: "start" } ) diff --git a/modules/backlogs/app/components/backlogs/inbox_component.rb b/modules/backlogs/app/components/backlogs/inbox_component.rb index b51c494dae4b..4cd9e652b781 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.rb +++ b/modules/backlogs/app/components/backlogs/inbox_component.rb @@ -76,6 +76,10 @@ def last_omitted_id private + def list_type + Backlogs::Target::InboxId.list_type + end + def tail_size [TRUNCATE_MIDDLE / 5, 1].max end diff --git a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb index 7abf18b853af..ce6f0c5e56b0 100644 --- a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb @@ -33,6 +33,7 @@ See COPYRIGHT and LICENSE files for more details. d.with_body do available_buckets = buckets + bucket_list_type = list_type primer_form_with( url: move_action, @@ -41,7 +42,7 @@ See COPYRIGHT and LICENSE files for more details. data: { turbo_stream: true } ) do |f| render_inline_form(f) do |form| - form.hidden(name: "list_type", value: "backlog_bucket") + form.hidden(name: "list_type", value: bucket_list_type) form.select_list(name: "list_id", label: BacklogBucket.human_attribute_name(:name), visually_hide_label: true) do |select| available_buckets.each do |bucket| select.option(label: bucket.name, value: bucket.id) diff --git a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb index bcb0e96979fa..14046c5bccbf 100644 --- a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb +++ b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb @@ -47,5 +47,11 @@ def initialize(work_package:, project:, move_action:) @buckets = BacklogBucket.where(project:).order_alphabetically @buckets = @buckets.where.not(id: work_package.backlog_bucket_id) if work_package.backlog_bucket_id end + + private + + def list_type + Backlogs::Target::BucketId.new(nil).list_type + end end end diff --git a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb index 93057cdf8c73..29698a64638d 100644 --- a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb @@ -33,6 +33,7 @@ See COPYRIGHT and LICENSE files for more details. d.with_body do available_sprints = sprints + sprint_list_type = list_type primer_form_with( url: move_action, @@ -41,7 +42,7 @@ See COPYRIGHT and LICENSE files for more details. data: { turbo_stream: true } ) do |f| render_inline_form(f) do |form| - form.hidden(name: "list_type", value: "sprint") + form.hidden(name: "list_type", value: sprint_list_type) form.select_list(name: "list_id", label: Sprint.human_model_name, visually_hide_label: true) do |select| available_sprints.each do |sprint| select.option(label: sprint.name, value: sprint.id) diff --git a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb index 63ae7fc8f1eb..bf0efcf387f2 100644 --- a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb +++ b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb @@ -47,5 +47,11 @@ def initialize(work_package:, project:, move_action:) @sprints = Sprint.for_project(@project).visible.not_completed.order_by_date @sprints = @sprints.where.not(id: work_package.sprint_id) if work_package.sprint_id end + + private + + def list_type + Backlogs::Target::SprintId.new(nil).list_type + end end end diff --git a/modules/backlogs/app/components/backlogs/sprint_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_component.html.erb index 6561c3224d40..e10941f8e5a5 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_component.html.erb @@ -35,7 +35,7 @@ See COPYRIGHT and LICENSE files for more details. project:, container: sprint, drag_and_drop: { - list_type: "sprint", + list_type:, list_id: sprint.id, drop_position: "start" }, diff --git a/modules/backlogs/app/components/backlogs/sprint_component.rb b/modules/backlogs/app/components/backlogs/sprint_component.rb index 31df5e478e7b..3a26ea4a4f29 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_component.rb @@ -56,6 +56,10 @@ def wrapper_uniq_by private + def list_type + Backlogs::Target::SprintId.new(sprint.id).list_type + end + def show_start_sprint_action? sprint.in_planning? && ::Backlogs::Sprints::StartContract.can_start?(user: current_user, sprint:, project:) end diff --git a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb index bceb533bf310..0dec48cba8e7 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb @@ -75,7 +75,7 @@ See COPYRIGHT and LICENSE files for more details. label: t(".action_menu.move_to_inbox"), tag: :button, href: move_project_backlogs_work_package_path(project, work_package), - form_arguments: { method: :put, inputs: [{ name: "list_type", value: "inbox" }] } + form_arguments: { method: :put, inputs: [{ name: "list_type", value: inbox_list_type }] } ) do |item| item.with_leading_visual_icon(icon: :inbox) end diff --git a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb index cf0cb506b4e1..3a3f701059f4 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb @@ -120,5 +120,9 @@ def current_list_inputs [{ name: "list_type", value: target.list_type }, { name: "list_id", value: target.list_id }] end + + def inbox_list_type + Backlogs::Target::InboxId.list_type + end end end diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index 3c183bae94be..fb5ddb54f008 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -119,9 +119,11 @@ def render_component end it "wires the list controller and value attributes for the bucket" do + list_type = Backlogs::Target::BucketId.new(backlog_bucket.id).list_type + expect(rendered_component).to have_css(".Box") do |box| expect(box["data-controller"]).to include("sortable-lists--list") - expect(box["data-sortable-lists--list-type-value"]).to eq("backlog_bucket") + expect(box["data-sortable-lists--list-type-value"]).to eq(list_type) expect(box["data-sortable-lists--list-id-value"]).to eq(backlog_bucket.id.to_s) expect(box["data-sortable-lists--list-drop-position-value"]).to eq("start") end diff --git a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb index c1d88d96edd7..02da358af14e 100644 --- a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb @@ -65,9 +65,11 @@ def render_component end it "wires the list controller for the inbox" do + list_type = Backlogs::Target::InboxId.list_type + expect(page).to have_css(".Box#inbox_project_#{project.id}") do |box| expect(box["data-controller"]).to include("sortable-lists--list") - expect(box["data-sortable-lists--list-type-value"]).to eq("inbox") + expect(box["data-sortable-lists--list-type-value"]).to eq(list_type) expect(box["data-sortable-lists--list-id-value"]).to be_nil expect(box["data-sortable-lists--list-drop-position-value"]).to eq("start") end diff --git a/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb b/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb index 72b2b0a28d50..6af17413abea 100644 --- a/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb @@ -81,7 +81,10 @@ def render_component it "submits backlog_bucket list data" do render_component - expect(page).to have_css("input[name='list_type'][value='backlog_bucket']", visible: :all) + expect(page).to have_css( + "input[name='list_type'][value='#{Backlogs::Target::BucketId.new(nil).list_type}']", + visible: :all + ) expect(page).to have_element(:option, value: bucket_a.id, text: "Alpha") expect(page).to have_element(:option, value: bucket_b.id, text: "Beta") end diff --git a/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb b/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb index 6e65c8c75874..7a4703ba1d53 100644 --- a/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb @@ -81,7 +81,10 @@ def render_component it "submits sprint list data" do render_component - expect(page).to have_css("input[name='list_type'][value='sprint']", visible: :all) + expect(page).to have_css( + "input[name='list_type'][value='#{Backlogs::Target::SprintId.new(nil).list_type}']", + visible: :all + ) expect(page).to have_css("option[value='#{planning_sprint.id}']", text: "Planning Sprint") expect(page).to have_css("option[value='#{active_sprint.id}']", text: "Active Sprint") end diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index 86ae5ab0b905..87f82c9540d7 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -102,9 +102,11 @@ def menu_items end it "wires the list controller and value attributes for the sprint" do + list_type = Backlogs::Target::SprintId.new(sprint.id).list_type + expect(rendered_component).to have_css(".Box") do |box| expect(box["data-controller"]).to include("sortable-lists--list") - expect(box["data-sortable-lists--list-type-value"]).to eq("sprint") + expect(box["data-sortable-lists--list-type-value"]).to eq(list_type) expect(box["data-sortable-lists--list-id-value"]).to eq(sprint.id.to_s) expect(box["data-sortable-lists--list-drop-position-value"]).to eq("start") end diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb index 20b3d18d3385..1053ce595341 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb @@ -349,7 +349,7 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_inbox\z/) expect(page).to have_octicon(:inbox) expect(page).to have_text(I18n.t(:"backlogs.work_package_card_menu_component.action_menu.move_to_inbox")) - expect(page).to have_field("list_type", type: :hidden, with: "inbox") + expect(page).to have_field("list_type", type: :hidden, with: Backlogs::Target::InboxId.list_type) end end From 8fbd268c970b31fc38f4d859d60bd972a42010d2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 14:26:51 +0100 Subject: [PATCH 081/105] Hoist sortable list PDD mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the runtime `vi.doMock` calls in `beforeAll` with hoisted top-level `vi.mock` declarations, so the specs no longer mutate the shared, non-isolated Vitest module registry mid-run — the source of the chromium-only spy-contention flake. --- .../dynamic/sortable-lists.controller.spec.ts | 56 +++++++++---------- .../sortable-lists/item.controller.spec.ts | 46 +++++++-------- .../sortable-lists/list.controller.spec.ts | 12 ++-- 3 files changed, 57 insertions(+), 57 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index cbdbebe5ed88..0146d696edb6 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -28,6 +28,34 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ +vi.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({ + draggable: vi.fn(() => vi.fn()), + dropTargetForElements: vi.fn(() => vi.fn()), + monitorForElements: vi.fn(() => vi.fn()), +})); + +vi.mock('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element', () => ({ + autoScrollForElements: vi.fn(() => vi.fn()), +})); + +// This spec mounts the real item controller, which pulls in these modules. +// Tests share one module registry (the runner does not isolate spec files), +// so importing the real versions here would leak into the item controller +// spec and break its spies. Mock them to keep the shared cache inert. +vi.mock('@atlaskit/pragmatic-drag-and-drop/combine', () => ({ + combine: vi.fn((...cleanups:(() => void)[]) => vi.fn(() => { + cleanups.forEach((cleanup) => cleanup()); + })), +})); + +vi.mock('@atlaskit/pragmatic-drag-and-drop/prevent-unhandled', () => ({ + preventUnhandled: { start: vi.fn(), stop: vi.fn() }, +})); + +vi.mock('@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview', () => ({ + setCustomNativeDragPreview: vi.fn(), +})); + import type { monitorForElements as monitorForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { waitFor } from '@testing-library/dom'; import { type Mock } from 'vitest'; @@ -52,34 +80,6 @@ describe('Sortable lists controller', () => { let renderStreamMessageMock:Mock; beforeAll(async () => { - vi.doMock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({ - draggable: vi.fn(() => vi.fn()), - dropTargetForElements: vi.fn(() => vi.fn()), - monitorForElements: vi.fn(() => vi.fn()), - })); - - vi.doMock('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element', () => ({ - autoScrollForElements: vi.fn(() => vi.fn()), - })); - - // This spec mounts the real item controller, which pulls in these modules. - // Tests share one module registry (the runner does not isolate spec files), - // so importing the real versions here would leak into the item controller - // spec and break its spies. Mock them to keep the shared cache inert. - vi.doMock('@atlaskit/pragmatic-drag-and-drop/combine', () => ({ - combine: vi.fn((...cleanups:(() => void)[]) => vi.fn(() => { - cleanups.forEach((cleanup) => cleanup()); - })), - })); - - vi.doMock('@atlaskit/pragmatic-drag-and-drop/prevent-unhandled', () => ({ - preventUnhandled: { start: vi.fn(), stop: vi.fn() }, - })); - - vi.doMock('@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview', () => ({ - setCustomNativeDragPreview: vi.fn(), - })); - ({ monitorForElements } = await import('@atlaskit/pragmatic-drag-and-drop/element/adapter')); ({ default: SortableListsController } = await import('./sortable-lists.controller')); ({ sortableItemData, sortableListData } = await import('./sortable-lists/drag-and-drop')); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index 41b947853648..d8eea08b425f 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -26,6 +26,29 @@ // See COPYRIGHT and LICENSE files for more details. //++ +vi.mock('@atlaskit/pragmatic-drag-and-drop/combine', () => ({ + combine: vi.fn((...cleanups:(() => void)[]) => vi.fn(() => { + cleanups.forEach((cleanup) => cleanup()); + })), +})); + +vi.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({ + draggable: vi.fn(() => vi.fn()), + dropTargetForElements: vi.fn(() => vi.fn()), + monitorForElements: vi.fn(() => vi.fn()), +})); + +vi.mock('@atlaskit/pragmatic-drag-and-drop/prevent-unhandled', () => ({ + preventUnhandled: { + start: vi.fn(), + stop: vi.fn(), + }, +})); + +vi.mock('@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview', () => ({ + setCustomNativeDragPreview: vi.fn(), +})); + import type { draggable as draggableFn, dropTargetForElements as dropTargetForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import type { setCustomNativeDragPreview as setCustomNativeDragPreviewFn } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import type { preventUnhandled as preventUnhandledType } from '@atlaskit/pragmatic-drag-and-drop/prevent-unhandled'; @@ -47,29 +70,6 @@ describe('Sortable lists item controller', () => { } beforeAll(async () => { - vi.doMock('@atlaskit/pragmatic-drag-and-drop/combine', () => ({ - combine: vi.fn((...cleanups:(() => void)[]) => vi.fn(() => { - cleanups.forEach((cleanup) => cleanup()); - })), - })); - - vi.doMock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({ - draggable: vi.fn(() => vi.fn()), - dropTargetForElements: vi.fn(() => vi.fn()), - monitorForElements: vi.fn(() => vi.fn()), - })); - - vi.doMock('@atlaskit/pragmatic-drag-and-drop/prevent-unhandled', () => ({ - preventUnhandled: { - start: vi.fn(), - stop: vi.fn(), - }, - })); - - vi.doMock('@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview', () => ({ - setCustomNativeDragPreview: vi.fn(), - })); - ({ draggable, dropTargetForElements } = await import('@atlaskit/pragmatic-drag-and-drop/element/adapter')); ({ preventUnhandled } = await import('@atlaskit/pragmatic-drag-and-drop/prevent-unhandled')); ({ setCustomNativeDragPreview } = await import('@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview')); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts index f88cc4af5ce6..d1d93dc98272 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts @@ -26,6 +26,12 @@ // See COPYRIGHT and LICENSE files for more details. //++ +vi.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({ + draggable: vi.fn(() => vi.fn()), + dropTargetForElements: vi.fn(() => vi.fn()), + monitorForElements: vi.fn(() => vi.fn()), +})); + import type { dropTargetForElements as dropTargetForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; import type ListControllerType from './list.controller'; @@ -44,12 +50,6 @@ describe('Sortable lists list controller', () => { let fixture:HTMLElement; beforeAll(async () => { - vi.doMock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({ - draggable: vi.fn(() => vi.fn()), - dropTargetForElements: vi.fn(() => vi.fn()), - monitorForElements: vi.fn(() => vi.fn()), - })); - ({ dropTargetForElements } = await import('@atlaskit/pragmatic-drag-and-drop/element/adapter')); ({ default: ListController } = await import('./list.controller')); ({ sortableItemData } = await import('./drag-and-drop')); From b9f317c9e4d188fe3e6bfbfa27ee1b60bd328ae4 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 15:10:53 +0100 Subject: [PATCH 082/105] Scope backlogs drag styling to the border-box list The `.Box-row[data-dragging="source"]` and `[data-drop-position]` rules sat at the top level of the shared border-box component, so the global `generic-drag-and-drop` `data-dragging` marker restyled dragged rows in admin and settings dragula lists. Nests them under `.op-border-box-list`, and renames the drop-gap custom properties to match that scope. --- .../common/border_box_list_component.sass | 77 ++++++++++--------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index 680591ada270..542bd548794e 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -170,42 +170,47 @@ .Box-row:has(> .Box-card[aria-current="true"]) @include op-box-list-item-edge-borders(var(--box-list-item-pressed-border-color)) -.Box-row[data-dragging="source"] - opacity: 0.4 - background-color: var(--bgColor-inset) - box-shadow: inset 0 0 0 var(--borderWidth-default) var(--borderColor-muted) - -.Box-row[data-drop-position] - --op-backlogs-drop-gap-border-width: 2px - --op-backlogs-drop-gap-height: 8px - - position: relative - - &::before, - &::after - position: absolute - left: 0 - right: 0 - height: var(--op-backlogs-drop-gap-height) - background: linear-gradient( - to bottom, - var(--bgColor-accent-muted) 0, - var(--bgColor-accent-muted) calc(50% - var(--op-backlogs-drop-gap-border-width) / 2), - var(--fgColor-accent) calc(50% - var(--op-backlogs-drop-gap-border-width) / 2), - var(--fgColor-accent) calc(50% + var(--op-backlogs-drop-gap-border-width) / 2), - var(--bgColor-accent-muted) calc(50% + var(--op-backlogs-drop-gap-border-width) / 2), - var(--bgColor-accent-muted) 100% - ) - pointer-events: none - z-index: 1 - - &[data-drop-position="top"]::before - content: '' - top: calc(var(--op-backlogs-drop-gap-height) / -2) - - &[data-drop-position="bottom"]::after - content: '' - bottom: calc(var(--op-backlogs-drop-gap-height) / -2) +// Drag-and-drop row states, scoped to the border-box list. The global +// generic-drag-and-drop controller sets `data-dragging="source"` on any dragged +// `.Box-row` (dragula lists in admin/settings), so these must not sit at the top +// level or they would restyle those unrelated rows. +.op-border-box-list + .Box-row[data-dragging="source"] + opacity: 0.4 + background-color: var(--bgColor-inset) + box-shadow: inset 0 0 0 var(--borderWidth-default) var(--borderColor-muted) + + .Box-row[data-drop-position] + --op-border-box-list-drop-gap-border-width: 2px + --op-border-box-list-drop-gap-height: 8px + + position: relative + + &::before, + &::after + position: absolute + left: 0 + right: 0 + height: var(--op-border-box-list-drop-gap-height) + background: linear-gradient( + to bottom, + var(--bgColor-accent-muted) 0, + var(--bgColor-accent-muted) calc(50% - var(--op-border-box-list-drop-gap-border-width) / 2), + var(--fgColor-accent) calc(50% - var(--op-border-box-list-drop-gap-border-width) / 2), + var(--fgColor-accent) calc(50% + var(--op-border-box-list-drop-gap-border-width) / 2), + var(--bgColor-accent-muted) calc(50% + var(--op-border-box-list-drop-gap-border-width) / 2), + var(--bgColor-accent-muted) 100% + ) + pointer-events: none + z-index: 1 + + &[data-drop-position="top"]::before + content: '' + top: calc(var(--op-border-box-list-drop-gap-height) / -2) + + &[data-drop-position="bottom"]::after + content: '' + bottom: calc(var(--op-border-box-list-drop-gap-height) / -2) // Empty-state wrapper. With a drop target label, an overlay covers the // blankslate while a sortable item hovers the list, labelling it as a drop From e99c2a814dcfbf199830971487dfe27f06a8ecd7 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 15:15:02 +0100 Subject: [PATCH 083/105] Guard optimistic rollback against detached DOM A list-refresh morph triggered by an unrelated WorkPackage update can replace the captured parent list mid-request. Restoring an optimistic move into that detached node dropped the row out of the live DOM until the next reload. Skips restoration when the captured parent is no longer connected, letting the pending refresh reconcile the position. --- .../dynamic/sortable-lists/list-dom.spec.ts | 30 +++++++++++++++++++ .../dynamic/sortable-lists/list-dom.ts | 5 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts index b18ca76c98ac..7ecd0ec8a6c9 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts @@ -143,11 +143,18 @@ describe('sortable lists DOM helpers', () => { }); describe('captureRowPositions / restoreRowPositions', () => { + // restoreRowPositions guards on the captured parent being connected, so these + // lists are mounted in the document to reflect the live frame they run against. + afterEach(() => { + document.body.replaceChildren(); + }); + it('restores a row to its original position after an optimistic move', () => { const list = listElement(); const [one, two, three] = ['1', '2', '3'].map(itemRow); list.append(one, two, three); + document.body.append(list); const snapshot = captureRowPositions([three]); reorderRows({ rows: [three], list, previousItemId: null }); @@ -162,6 +169,7 @@ describe('sortable lists DOM helpers', () => { const [one, two, three, four] = ['1', '2', '3', '4'].map(itemRow); list.append(one, two, three, four); + document.body.append(list); const snapshot = captureRowPositions([two, three]); reorderRows({ rows: [two, three], list, previousItemId: '4' }); @@ -176,11 +184,33 @@ describe('sortable lists DOM helpers', () => { const [one, two] = ['1', '2'].map(itemRow); list.append(one, two); + document.body.append(list); const snapshot = captureRowPositions([one]); two.remove(); expect(() => restoreRowPositions(snapshot)).not.toThrow(); expect(itemIdOrder(list)).toEqual(['1']); }); + + it('does not restore a row into a captured parent that has detached', () => { + const list = listElement(); + const destination = listElement(); + const [one, two] = ['1', '2'].map(itemRow); + + list.append(one, two); + document.body.append(list, destination); + const snapshot = captureRowPositions([one]); + + // Optimistic move relocates the row into a live destination list. + destination.append(one); + // A mid-flight list-refresh morph detaches the original list. + list.remove(); + + restoreRowPositions(snapshot); + + // The row is not pulled back into the detached list; it stays live. + expect(one.isConnected).toBe(true); + expect(itemIdOrder(destination)).toEqual(['1']); + }); }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts index a78d6b638f71..a098c8eb552d 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts @@ -128,7 +128,10 @@ export function captureRowPositions(rows:HTMLElement[]):RowPlacement[] { export function restoreRowPositions(positions:RowPlacement[]):void { for (let i = positions.length - 1; i >= 0; i -= 1) { const { row, parent, nextSibling } = positions[i]; - if (!parent) { + // A list-refresh morph can replace the captured parent mid-request; restoring + // into a detached node would drop the row out of the live DOM until the next + // reload. Skip it and let the pending refresh reconcile the position. + if (!parent?.isConnected) { continue; } From 7f3e2617b43b2768adad10b914e81cc6df8ee4eb Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 15:23:41 +0100 Subject: [PATCH 084/105] Clear drop indicator when a sortable item disconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderDropIndicatorOn` writes the drop position onto the item's next sibling. When a list-refresh morph removes the hovering item mid-drag, its controller disconnects without an `onDrop`, and no other controller may clear a foreign owner — so the indicator lingered as a phantom drop line until a full reload. Clears the owned indicator in `disconnect`. --- .../sortable-lists/item.controller.spec.ts | 22 +++++++++++++++++++ .../dynamic/sortable-lists/item.controller.ts | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index d8eea08b425f..6e1a99c5047c 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -67,6 +67,7 @@ describe('Sortable lists item controller', () => { interface TestItemController { renderDropIndicator(edge:'top'|'bottom'|null):void; clearDropIndicator():void; + disconnect():void; } beforeAll(async () => { @@ -253,6 +254,27 @@ describe('Sortable lists item controller', () => { expect(nextElement.dataset.dropPositionOwner).toEqual('2'); }); + it('clears its own drop indicator when the item disconnects mid-drag', () => { + const element = document.createElement('li'); + const nextElement = document.createElement('li'); + const controller = controllerFor(element); + + element.setAttribute('data-sortable-lists--item-id-value', '1'); + nextElement.setAttribute('data-sortable-lists--item-id-value', '2'); + document.body.append(element, nextElement); + + controller.renderDropIndicator('bottom'); + expect(nextElement.dataset.dropPosition).toEqual('top'); + + // A list-refresh morph removes the hovering row: its controller disconnects + // without an onDrop, so the indicator it owns on the sibling must be cleared + // here or it lingers forever. + controller.disconnect(); + + expect(nextElement.hasAttribute('data-drop-position')).toBe(false); + expect(nextElement.hasAttribute('data-drop-position-owner')).toBe(false); + }); + it('keeps the item drop target active while moving through row gaps', () => { const element = document.createElement('article'); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index 912ea7f9785f..9350fbc5c35e 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -102,6 +102,10 @@ export default class ItemController extends Controller implements R } disconnect():void { + // A morph can remove a hovering row mid-drag; without this the drop indicator + // it owns on a sibling row is never cleared (no onDrop fires, and no other + // controller may clear a foreign owner), leaving a phantom drop line. + this.clearDropIndicator(); this.cleanupFn?.(); this.cleanupFn = undefined; this.disconnectRoot(); From f1be7232db3972c5c13ff3f8bce8137e08354f3c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 15:24:45 +0100 Subject: [PATCH 085/105] Document the sortable-lists drop container The frontend ubiquitous-language doc named only the per-item Drop target, yet the code also outlines the whole list via `data-drop-container`. Adds a Drop container term for that list-level state, documents the wired `drop-position-value` in the consumer contract, and aligns the sass comment. Keeps the existing `_Avoid_: Container` notes, since Drop container is a distinct state, not a synonym for a target or list. --- .../common/border_box_list_component.sass | 4 ++-- frontend/CONTEXT.md | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index 542bd548794e..da3782dc65be 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -87,8 +87,8 @@ border-bottom-left-radius: 0 border-bottom-right-radius: 0 - // Container drop zone: the whole list outlines while a sortable item hovers - // it, signalling the item will land inside this list. + // Drop container: the whole list outlines while a sortable item hovers it, + // signalling the item will land inside this list. &[data-drop-container] outline: var(--borderWidth-thick) solid var(--fgColor-accent) outline-offset: calc(var(--borderWidth-thick) * -1) diff --git a/frontend/CONTEXT.md b/frontend/CONTEXT.md index 7638cdbd337b..352fa978feba 100644 --- a/frontend/CONTEXT.md +++ b/frontend/CONTEXT.md @@ -24,6 +24,11 @@ _Avoid_: Drop indicator An area that can accept or reject a dragged object. _Avoid_: Container +**Drop container**: +The whole-list active outline shown while a dragged object hovers a sortable list, +signalling it will land inside. A list-level state — distinct from a per-item **Drop +target** edge — carried by the list's `data-drop-container` attribute. + **Empty drop zone**: An empty area that can accept a dragged object when there is no existing object to anchor a drop indicator. _Avoid_: Placeholder @@ -48,6 +53,7 @@ _Avoid_: Container - An **Empty drop zone** may combine the affordances of a **Drop target**, **Drop indicator**, and **Drop placeholder**. - A **Sortable item** has a **Sortable item type**. - A **Sortable list** may accept only specific **Sortable item types**. +- An active **Sortable list** shows a **Drop container** outline while a compatible **Sortable item** hovers it, distinct from the per-item **Drop indicator**. ## Sortable lists wiring (consumer contract) @@ -70,9 +76,13 @@ monitor, and auto-scroll): descendant lists and items so the root can hand each its reference. - Target `scrollable`: each element that should auto-scroll during a drag. -**List — `sortable-lists--list`** (a **Drop target**): +**List — `sortable-lists--list`** (a **Drop target** that shows a **Drop container** +outline while active): - `type-value` (**required** to be a drop target; a list without it is inert). - `id-value` (optional): the list id sent as `list_id`. +- `drop-position-value` (default `end`): where a drop that resolves to the list + itself, rather than to an item edge, inserts — `start` files the item at the top, + `end` appends it. **Item — `sortable-lists--item`** (a **Sortable item**; the drag source and edge indicator owner): From bb7f5f44000ec6fa3d22d49e801e0259758cffb0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 16:08:20 +0100 Subject: [PATCH 086/105] Remove dead backlogs spec page helpers Drops the unused `require "json"` and the `expect_backlog_blankslate*` aliases, which had no callers. --- modules/backlogs/spec/support/pages/backlog.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index f630d4d004ce..d694537fcdcb 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -29,7 +29,6 @@ #++ require "support/pages/page" -require "json" module Pages class Backlog < Page @@ -95,10 +94,6 @@ def expect_no_sprints_blankslate end end - alias_method :expect_backlog_blankslate, :expect_sprints_blankslate - alias_method :expect_backlog_blankslate_description, :expect_sprints_blankslate_description - alias_method :expect_no_backlog_blankslate, :expect_no_sprints_blankslate - def expect_backlog_settings_link within_sprint_backlogs do expect(page).to have_link( From 3777403ee441c352a07f0449fee01b217c5f1b26 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 16:09:54 +0100 Subject: [PATCH 087/105] Tidy backlogs move controller conventions Uses 2-space continuation indent for the `move` service call and the `target_open_sprints` / `target_buckets` query chains instead of paren-aligned indent, and moves `move_params` / `move_service_params` to the bottom of the private section. --- .../backlogs/work_packages_controller.rb | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb index 9e2f23c528c1..621c479e8cef 100644 --- a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb @@ -68,7 +68,7 @@ def move_to_bucket_dialog def move # rubocop:disable Metrics/AbcSize call = ::Backlogs::WorkPackages::UpdateService.new(user: current_user, story: @work_package) - .call(**move_service_params) + .call(**move_service_params) if call.success? reload_frame_via_turbo_stream("backlogs_container") @@ -103,17 +103,6 @@ def load_work_package @work_package = @work_packages.find(params.expect(:id)) end - def move_params - params.permit(:prev_id, :position, :direction, :list_type, :list_id) - end - - # A blank prev_id (drag or menu move to the top of a list) is kept so the - # service inserts at the top; nil values (absent prev_id/direction) are - # dropped. The service resolves list_type/list_id into the destination list. - def move_service_params - move_params.to_h.symbolize_keys.compact - end - def displayed_work_packages if @work_package.sprint_id? @work_packages.where(sprint_id: @work_package.sprint_id) @@ -130,13 +119,13 @@ def work_package_with_backlog_neighbours def target_open_sprints Sprint.for_project(@project) - .visible.not_completed - .where.not(id: @work_package.sprint_id) + .visible.not_completed + .where.not(id: @work_package.sprint_id) end def target_buckets BacklogBucket.where(project: @project) - .where.not(id: @work_package.backlog_bucket_id) + .where.not(id: @work_package.backlog_bucket_id) end # After a work package is moved to the backlog, it might no longer be visible due to @@ -147,5 +136,16 @@ def work_package_invisible_after_move?(work_package) @project.backlog_excluded_type_ids.include?(work_package.type_id) || @project.done_status_ids.include?(work_package.status_id) end + + def move_params + params.permit(:prev_id, :position, :direction, :list_type, :list_id) + end + + # A blank prev_id (drag or menu move to the top of a list) is kept so the + # service inserts at the top; nil values (absent prev_id/direction) are + # dropped. The service resolves list_type/list_id into the destination list. + def move_service_params + move_params.to_h.symbolize_keys.compact + end end end From cbba52dc48bd2d7a40d112f100b791c25c02add5 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 16:14:05 +0100 Subject: [PATCH 088/105] Wrap over-long backlogs lines Breaks the two card-list data guards, the bucket-dialog select-list arguments, and the item controller's missing-value warnings across lines to stay within the 120-column guide. --- .../dynamic/sortable-lists/item.controller.ts | 10 ++++++++-- .../backlogs/move_to_bucket_dialog_component.html.erb | 6 +++++- .../backlogs/work_package_card_list_component.rb | 8 ++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index 9350fbc5c35e..e6d6f636f1a0 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -125,11 +125,17 @@ export default class ItemController extends Controller implements R // appear draggable yet silently refuse every drop. Surface that wiring mistake. private warnOnMissingValues():void { if (!this.hasIdValue) { - console.warn('sortable-lists--item is missing its required id value (data-sortable-lists--item-id-value); it cannot be moved.', this.element); + console.warn( + 'sortable-lists--item is missing its required id value (data-sortable-lists--item-id-value); it cannot be moved.', + this.element, + ); } if (!this.hasTypeValue) { - console.warn('sortable-lists--item is missing its required type value (data-sortable-lists--item-type-value); it cannot be moved.', this.element); + console.warn( + 'sortable-lists--item is missing its required type value (data-sortable-lists--item-type-value); it cannot be moved.', + this.element, + ); } } diff --git a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb index ce6f0c5e56b0..366c690a9e72 100644 --- a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb @@ -43,7 +43,11 @@ See COPYRIGHT and LICENSE files for more details. ) do |f| render_inline_form(f) do |form| form.hidden(name: "list_type", value: bucket_list_type) - form.select_list(name: "list_id", label: BacklogBucket.human_attribute_name(:name), visually_hide_label: true) do |select| + form.select_list( + name: "list_id", + label: BacklogBucket.human_attribute_name(:name), + visually_hide_label: true + ) do |select| available_buckets.each do |bucket| select.option(label: bucket.name, value: bucket.id) end diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb index 1df92178b424..8bb1fab3203f 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb @@ -119,8 +119,12 @@ def drag_and_drop_data controller: "sortable-lists--list", sortable_lists__list_type_value: drag_and_drop.fetch(:list_type) } - data[:sortable_lists__list_id_value] = drag_and_drop[:list_id] if drag_and_drop[:list_id].present? - data[:sortable_lists__list_drop_position_value] = drag_and_drop[:drop_position] if drag_and_drop[:drop_position].present? + if drag_and_drop[:list_id].present? + data[:sortable_lists__list_id_value] = drag_and_drop[:list_id] + end + if drag_and_drop[:drop_position].present? + data[:sortable_lists__list_drop_position_value] = drag_and_drop[:drop_position] + end data end From 4f008949225693c2576b4d8d76569aae52b19a60 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 16:14:37 +0100 Subject: [PATCH 089/105] Refresh stale sortable-lists controller comments Updates the selected/current-state comments to name the `backlogs--work-package` controller, renamed from `backlogs--story` earlier on this branch. --- .../open_project/common/border_box_list_component.sass | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index da3782dc65be..da274246ae42 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -153,9 +153,9 @@ &:has(> .Box-card:focus-visible) background-color: var(--bgColor-muted) -// Selected state: applied synchronously on activation by the backlogs--story -// controller (single selection), so the list reacts before the split screen -// has loaded. +// Selected state: applied synchronously on activation by the +// backlogs--work-package controller (single selection), so the list reacts +// before the split screen has loaded. .Box-row:has(> .Box-card[data-selected]) background-color: var(--box-list-item-selected-bg-color) @include op-box-list-item-edge-borders(var(--box-list-item-selected-border-color)) @@ -165,8 +165,8 @@ background-color: var(--box-list-item-selected-bg-hover-color) // Current state: the card is open in the split screen. aria-current is -// maintained by the backlogs--story controller once the URL reflects the -// details pane; the strong border wins over a simultaneous selected state. +// maintained by the backlogs--work-package controller once the URL reflects +// the details pane; the strong border wins over a simultaneous selected state. .Box-row:has(> .Box-card[aria-current="true"]) @include op-box-list-item-edge-borders(var(--box-list-item-pressed-border-color)) From 5b4ae955fb42c4434bbc620e904160a003300ad2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 16:16:09 +0100 Subject: [PATCH 090/105] Narrow resolveDropIntent with type guards Types the `dropTargets.find` predicates as type guards so the target item and list narrow to `HTMLElement` with their payload types, dropping the three redundant re-checks that followed. --- .../dynamic/sortable-lists/drag-and-drop.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index baeb539ff5f4..19d1e29583d1 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -250,27 +250,28 @@ export function resolveDropIntent({ sourceElement:HTMLElement; sourceData:SortableItemData; }):DropIntent|null { - const targetItem = location.current.dropTargets.find(({ data, element }) => ( - isSortableItemData(data) && element instanceof HTMLElement && root.contains(element) - )); - const targetList = location.current.dropTargets.find(({ data, element }) => ( - isSortableListData(data) && element instanceof HTMLElement && root.contains(element) - )); - if (!(targetList?.element instanceof HTMLElement)) { + const targetItem = location.current.dropTargets.find( + (target):target is typeof target & { data:SortableItemData; element:HTMLElement } => ( + isSortableItemData(target.data) && target.element instanceof HTMLElement && root.contains(target.element) + ), + ); + const targetList = location.current.dropTargets.find( + (target):target is typeof target & { data:SortableListData; element:HTMLElement } => ( + isSortableListData(target.data) && target.element instanceof HTMLElement && root.contains(target.element) + ), + ); + if (!targetList) { return null; } const listElement = targetList.element; const listData = targetList.data; - if (!isSortableListData(listData)) { - return null; - } if (!targetItem && isSourceListTarget({ sourceElement, targetElement: listElement })) { return null; } - const previousItemId = targetItem?.element instanceof HTMLElement + const previousItemId = targetItem ? resolvePreviousSortableItemId({ sourceItemId: sourceData.itemId, targetItem: targetItem.element, From 2a617b66b02adc724c38252621999a2c98b33052 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 16:18:50 +0100 Subject: [PATCH 091/105] Skip the source list as a list-level drop target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drop onto the list from a row already in it is a no-op — the item drop targets own within-list reorder — yet the list-level `canDrop` still accepted it, so the source list painted its container outline for nothing. Adds the `isSourceListTarget` exclusion `resolveDropIntent` already applies. --- .../sortable-lists/list.controller.spec.ts | 18 ++++++++++++++++++ .../dynamic/sortable-lists/list.controller.ts | 13 +++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts index d1d93dc98272..08ceedbdcbfe 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts @@ -165,6 +165,24 @@ describe('Sortable lists list controller', () => { .toBe(true); }); + it('rejects a source dragged from within this list as a list-level target', async () => { + const root = document.createElement('div'); + const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package' }) }); + + // A drop onto the list itself (no item target) from a row already in this + // list is a guaranteed no-op — the item targets own within-list reorder — so + // the list must not accept it and paint its container outline. + const sourceRow = document.createElement('li'); + list.append(sourceRow); + const sourceInList = { + data: sortableItemData({ itemId: '1', type: 'work_package', rootElement: root }), + element: sourceRow, + } as never; + + expect(dropTargetOptionsFor(list)?.canDrop?.({ element: list, input: {} as never, source: sourceInList })) + .toBe(false); + }); + it('rejects an item whose type is not accepted', async () => { const root = document.createElement('div'); const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package' }) }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts index df790b9c7711..fb92426a56ac 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts @@ -32,6 +32,7 @@ import { Controller } from '@hotwired/stimulus'; import { canAccept, isSortableItemData, + isSourceListTarget, sortableListData, type RootAwareChild, type SortableListData, @@ -67,7 +68,7 @@ export default class ListController extends Controller implements R this.cleanupFn = dropTargetForElements({ element: this.element, - canDrop: ({ source }) => this.canDrop(source.data), + canDrop: ({ source }) => this.canDrop(source.data, source.element), getData: () => this.listData, getIsSticky: () => false, onDragEnter: ({ location }) => { @@ -124,12 +125,20 @@ export default class ListController extends Controller implements R }); } - private canDrop(data:Record):boolean { + private canDrop(data:Record, sourceElement:Element):boolean { const { root } = this; if (root == null || root.moving) { return false; } + // A drop onto this list from a row already in it is a guaranteed no-op (the + // item drop targets own within-list reorder), so refuse it here — otherwise + // the source list would paint its container outline for a drop that resolves + // to nothing. Mirrors the source-list exclusion in resolveDropIntent. + if (isSourceListTarget({ sourceElement, targetElement: this.element })) { + return false; + } + return canAccept(root, data); } From 850bb5197e76a27195e10777931b6efbdde388cf Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 16:23:46 +0100 Subject: [PATCH 092/105] Extract the sortable-lists toaster event constant Moves the `op:toasters:add` event name into a dependency-free `toast-event` module so the Stimulus controller can dispatch it via the shared `OPToastEvent` constant without importing the Angular `ToastService` graph; `toast.service` now re-exports it. Also drops an unused filter arg the touched file carried. --- .../shared/components/toaster/toast-event.ts | 32 +++++++++++++++++++ .../components/toaster/toast.service.ts | 5 +-- .../dynamic/sortable-lists.controller.ts | 3 +- 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/shared/components/toaster/toast-event.ts diff --git a/frontend/src/app/shared/components/toaster/toast-event.ts b/frontend/src/app/shared/components/toaster/toast-event.ts new file mode 100644 index 000000000000..52bc3f154bcc --- /dev/null +++ b/frontend/src/app/shared/components/toaster/toast-event.ts @@ -0,0 +1,32 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +// The toaster event name lives in this dependency-free module so non-Angular +// callers (e.g. Stimulus controllers) can dispatch `op:toasters:add` without +// pulling in the Angular ToastService graph. Re-exported by toast.service.ts. +export const OPToastEvent = 'op:toasters:add'; diff --git a/frontend/src/app/shared/components/toaster/toast.service.ts b/frontend/src/app/shared/components/toaster/toast.service.ts index 2acf4fd63884..0bea3d75e9ca 100644 --- a/frontend/src/app/shared/components/toaster/toast.service.ts +++ b/frontend/src/app/shared/components/toaster/toast.service.ts @@ -36,13 +36,14 @@ import { I18nService } from 'core-app/core/i18n/i18n.service'; import { ConfigurationService } from 'core-app/core/config/configuration.service'; import waitForUploadsFinished from 'core-app/core/upload/wait-for-uploads-finished'; import { IHalErrorBase, IHalMultipleError, isHalError } from 'core-app/features/hal/resources/error-resource'; +import { OPToastEvent } from './toast-event'; export function removeSuccessFlashMessages():void { document.querySelectorAll('.op-toast.-success').forEach((flashMessage) => flashMessage.remove()); } export type ToastType = 'success'|'error'|'warning'|'info'|'upload'|'loading'; -export const OPToastEvent = 'op:toasters:add'; +export { OPToastEvent }; export interface IToast { message:string; @@ -79,7 +80,7 @@ export class ToastService { this.stack.doModify((current) => { const nextValue = [toast].concat(current); - return [nextValue[0]].concat(nextValue.slice(1).filter((n, i) => !this.removeOnAdd(n))); + return [nextValue[0]].concat(nextValue.slice(1).filter((n) => !this.removeOnAdd(n))); }); // auto-hide if success diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 342539459656..a80ba7256693 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -33,6 +33,7 @@ import { import { Controller } from '@hotwired/stimulus'; import { FetchRequest } from '@rails/request.js'; import { debugLog } from 'core-app/shared/helpers/debug_output'; +import { OPToastEvent } from 'core-app/shared/components/toaster/toast-event'; import { flipMove } from 'core-stimulus/helpers/flip-helper'; import { parseTemplate } from 'url-template'; import { @@ -263,7 +264,7 @@ export default class SortableListsController extends Controller imp } private dispatchErrorToast():void { - window.dispatchEvent(new CustomEvent('op:toasters:add', { + window.dispatchEvent(new CustomEvent(OPToastEvent, { detail: { message: I18n.t('js.error.internal'), type: 'error', From eff6cc4b0b57a45c7ea09580d29a12624177f49c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 20:13:43 +0100 Subject: [PATCH 093/105] Revert "Skip the source list as a list-level drop target" This reverts commit 2a617b66b02adc724c38252621999a2c98b33052. --- .../sortable-lists/list.controller.spec.ts | 18 ------------------ .../dynamic/sortable-lists/list.controller.ts | 13 ++----------- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts index 08ceedbdcbfe..d1d93dc98272 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts @@ -165,24 +165,6 @@ describe('Sortable lists list controller', () => { .toBe(true); }); - it('rejects a source dragged from within this list as a list-level target', async () => { - const root = document.createElement('div'); - const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package' }) }); - - // A drop onto the list itself (no item target) from a row already in this - // list is a guaranteed no-op — the item targets own within-list reorder — so - // the list must not accept it and paint its container outline. - const sourceRow = document.createElement('li'); - list.append(sourceRow); - const sourceInList = { - data: sortableItemData({ itemId: '1', type: 'work_package', rootElement: root }), - element: sourceRow, - } as never; - - expect(dropTargetOptionsFor(list)?.canDrop?.({ element: list, input: {} as never, source: sourceInList })) - .toBe(false); - }); - it('rejects an item whose type is not accepted', async () => { const root = document.createElement('div'); const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package' }) }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts index fb92426a56ac..df790b9c7711 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts @@ -32,7 +32,6 @@ import { Controller } from '@hotwired/stimulus'; import { canAccept, isSortableItemData, - isSourceListTarget, sortableListData, type RootAwareChild, type SortableListData, @@ -68,7 +67,7 @@ export default class ListController extends Controller implements R this.cleanupFn = dropTargetForElements({ element: this.element, - canDrop: ({ source }) => this.canDrop(source.data, source.element), + canDrop: ({ source }) => this.canDrop(source.data), getData: () => this.listData, getIsSticky: () => false, onDragEnter: ({ location }) => { @@ -125,20 +124,12 @@ export default class ListController extends Controller implements R }); } - private canDrop(data:Record, sourceElement:Element):boolean { + private canDrop(data:Record):boolean { const { root } = this; if (root == null || root.moving) { return false; } - // A drop onto this list from a row already in it is a guaranteed no-op (the - // item drop targets own within-list reorder), so refuse it here — otherwise - // the source list would paint its container outline for a drop that resolves - // to nothing. Mirrors the source-list exclusion in resolveDropIntent. - if (isSourceListTarget({ sourceElement, targetElement: this.element })) { - return false; - } - return canAccept(root, data); } From 9007cc47a5a708b455bf1b0d8b229ad3c08d2ab7 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 21:32:49 +0100 Subject: [PATCH 094/105] Colocate the sortable list busy state on the list Moves the aria-busy progress/dim rule out of the backlogs page module and onto `.op-border-box-list`, beside its sibling drop state. The rule targeted the sortable list by its Stimulus controller attribute; `.op-border-box-list` names the same element and keeps the busy styling next to the list it belongs to. --- .../common/border_box_list_component.sass | 11 +++++++++++ .../src/global_styles/content/modules/_backlogs.sass | 9 --------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass index da274246ae42..3b870e1dcea8 100644 --- a/app/components/open_project/common/border_box_list_component.sass +++ b/app/components/open_project/common/border_box_list_component.sass @@ -94,6 +94,17 @@ outline-offset: calc(var(--borderWidth-thick) * -1) border-radius: var(--borderRadius-medium) + // While a move persists the list is aria-busy: show progress and dim the + // rows until the server render reconciles them. + &[aria-busy="true"] + cursor: progress + + .Box-list + opacity: 0.7 + + @media (prefers-reduced-motion: no-preference) + transition: opacity 0.1s ease-in-out + // The empty-state row only makes sense while it is alone in the list. Hiding // it as soon as a sibling row appears (e.g. dropped by drag and drop) avoids // a flicker between the drop and the server render removing it. diff --git a/frontend/src/global_styles/content/modules/_backlogs.sass b/frontend/src/global_styles/content/modules/_backlogs.sass index f4f8a5ea82e2..6bcf06788d62 100644 --- a/frontend/src/global_styles/content/modules/_backlogs.sass +++ b/frontend/src/global_styles/content/modules/_backlogs.sass @@ -49,15 +49,6 @@ container-name: backlogsListsContainer container-type: inline-size - [data-controller~="sortable-lists--list"][aria-busy="true"] - cursor: progress - - .Box-list - opacity: 0.7 - - @media (prefers-reduced-motion: no-preference) - transition: opacity 0.1s ease-in-out - .op-sprint-planning-container display: flex flex-direction: row From 1277399dfd6bb1e257731241d40210af6c1b5afd Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 16:25:26 +0100 Subject: [PATCH 095/105] Cap the backlogs DnD pickup retry via retry_block Replaces the unbounded `loop`/`next`-on-stale-element retry in `pick_up_and_release_work_package` with the shared `retry_block` helper, capped at three tries on the stale-node errors. Because `retry_block` no-ops under `RSPEC_RETRY_RETRY_COUNT=0`, single and local runs now fail fast on a stale node instead of spinning, so the underlying fault surfaces for the follow-up rather than being masked. --- .../backlogs/spec/support/pages/backlog.rb | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index d694537fcdcb..7339fd50e260 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -442,15 +442,24 @@ def expect_work_package_not_draggable(work_package) end def pick_up_and_release_work_package(work_package) - loop do + # A mid-drag list refresh can detach the grabbed row, so retry a bounded + # number of times on a stale node. retry_block no-ops under + # RSPEC_RETRY_RETRY_COUNT=0, so single/local runs fail fast and surface the + # underlying fault instead of spinning on it. See the follow-up to make this + # helper robust without any retry. + retry_block( + args: { + tries: 3, + on: [ + Capybara::Cuprite::ObsoleteNode, + Selenium::WebDriver::Error::StaleElementReferenceError + ] + } + ) do moved_element = find(draggable_work_package_selector(work_package)) - + install_backlogs_move_request_probe begin - install_backlogs_move_request_probe pick_up_and_release_backlogs_item(moved_element) - return - rescue Capybara::Cuprite::ObsoleteNode, Selenium::WebDriver::Error::StaleElementReferenceError - next ensure stop_backlogs_move_request_probe end From c330ed0b3c8a723706c2622de482be8e9d93a91e Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 21:39:46 +0100 Subject: [PATCH 096/105] Extract the sortable item drag preview builder Moves the drag-preview clone, sanitisation and Box density copying out of the item controller into a `preview` helper, leaving the controller to wire pragmatic drag and drop. The sanitisation and density logic is a pure DOM transform, so it gains a focused spec that exercises it without booting a controller. --- .../dynamic/sortable-lists/item.controller.ts | 67 +------- .../dynamic/sortable-lists/preview.spec.ts | 162 ++++++++++++++++++ .../dynamic/sortable-lists/preview.ts | 99 +++++++++++ 3 files changed, 267 insertions(+), 61 deletions(-) create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/preview.spec.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/preview.ts diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index e6d6f636f1a0..47fd8b18b0f5 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -47,30 +47,10 @@ import { type SortableListsRoot, } from './drag-and-drop'; import { sortableItemSelector } from './list-dom'; +import { renderDragPreview } from './preview'; type CleanupFn = () => void; -// Attributes stripped from the cloned drag preview so it carries no behaviour or -// stale interaction state. The dynamic `data-*--*-target` attributes are removed -// separately in sanitizePreview. -const PREVIEW_STRIPPED_ATTRIBUTES = [ - 'data-controller', - 'data-action', - 'data-dragging', - 'data-drop-position', - 'data-drop-position-owner', - 'data-selected', - 'aria-current', - 'aria-describedby', - 'aria-disabled', - 'aria-roledescription', -] as const; - -// Box density variant classes copied onto the drag-preview container. The preview is -// mounted outside the originating Box, so variant-scoped card styles (e.g. -// `.Box--condensed .Box-card`) would not apply to it otherwise. -const BOX_DENSITY_VARIANT_CLASSES = ['Box--condensed', 'Box--spacious'] as const; - export default class ItemController extends Controller implements RootAwareChild { static targets = ['handle', 'preview']; @@ -171,7 +151,11 @@ export default class ItemController extends Controller implements R element: this.previewTarget, input: location.current.input, }), - render: ({ container }) => this.renderPreview(container), + render: ({ container }) => renderDragPreview({ + previewTarget: this.previewTarget, + sourceElement: this.element, + container, + }), }); }, }); @@ -237,45 +221,6 @@ export default class ItemController extends Controller implements R }); } - private renderPreview(container:HTMLElement) { - const previewWidth = this.previewTarget.getBoundingClientRect().width; - const preview = this.previewTarget.cloneNode(true) as HTMLElement; - - this.sanitizePreview(preview); - preview.setAttribute('data-preview', ''); - - if (previewWidth > 0) { - preview.style.width = `${previewWidth}px`; - } - - const box = this.element.closest('.Box'); - - BOX_DENSITY_VARIANT_CLASSES.forEach((variant) => { - if (box?.classList.contains(variant)) { - container.classList.add(variant); - } - }); - - container.append(preview); - } - - private sanitizePreview(element:HTMLElement) { - // Avoid side effects from custom elements (e.g. Primer include-fragment) in the cloned preview. - element.querySelectorAll('include-fragment').forEach((fragment) => fragment.remove()); - - const nodes = [element, ...Array.from(element.querySelectorAll('*'))]; - - for (const node of nodes) { - PREVIEW_STRIPPED_ATTRIBUTES.forEach((attribute) => node.removeAttribute(attribute)); - - for (const attribute of Array.from(node.attributes)) { - if (/^data-.+--.+-target$/.test(attribute.name)) { - node.removeAttribute(attribute.name); - } - } - } - } - private renderDropIndicator(edge:Edge|null) { const currentEdge = this.dropIndicatorElement?.dataset.dropPosition; const currentOwner = this.dropIndicatorElement?.dataset.dropPositionOwner; diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/preview.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/preview.spec.ts new file mode 100644 index 000000000000..ce52c4ff749b --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/preview.spec.ts @@ -0,0 +1,162 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { renderDragPreview, sanitizePreview } from './preview'; + +describe('sortable lists drag preview', () => { + function previewTarget():HTMLElement { + const article = document.createElement('article'); + article.setAttribute('data-controller', 'backlogs--work-package'); + article.setAttribute('data-action', 'click->backlogs--work-package#onClick'); + article.setAttribute('data-sortable-lists--item-target', 'preview'); + article.setAttribute('data-dragging', 'source'); + article.setAttribute('data-drop-position', 'top'); + article.setAttribute('data-drop-position-owner', '123'); + article.setAttribute('data-selected', ''); + article.setAttribute('aria-current', 'true'); + article.setAttribute('aria-roledescription', 'draggable'); + + const fragment = document.createElement('include-fragment'); + fragment.setAttribute('src', '/lazy'); + article.appendChild(fragment); + + const child = document.createElement('div'); + child.setAttribute('data-controller', 'nested'); + child.setAttribute('data-nested--thing-target', 'child'); + article.appendChild(child); + + return article; + } + + describe('sanitizePreview', () => { + it('strips behaviour and interaction-state attributes from the clone', () => { + const element = previewTarget(); + + sanitizePreview(element); + + expect(element.hasAttribute('data-controller')).toBe(false); + expect(element.hasAttribute('data-action')).toBe(false); + expect(element.hasAttribute('data-sortable-lists--item-target')).toBe(false); + expect(element.hasAttribute('data-dragging')).toBe(false); + expect(element.hasAttribute('data-drop-position')).toBe(false); + expect(element.hasAttribute('data-drop-position-owner')).toBe(false); + expect(element.hasAttribute('data-selected')).toBe(false); + expect(element.hasAttribute('aria-current')).toBe(false); + expect(element.hasAttribute('aria-roledescription')).toBe(false); + }); + + it('strips dynamic target attributes from descendants', () => { + const element = previewTarget(); + + sanitizePreview(element); + + expect(element.querySelector('[data-controller]')).toBeNull(); + expect(element.querySelector('[data-nested--thing-target]')).toBeNull(); + }); + + it('removes include-fragment elements so no lazy fetch fires from the clone', () => { + const element = previewTarget(); + + sanitizePreview(element); + + expect(element.querySelector('include-fragment')).toBeNull(); + }); + }); + + describe('renderDragPreview', () => { + function withWidth(element:HTMLElement, width:number):HTMLElement { + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: width, + bottom: 64, + width, + height: 64, + toJSON: vi.fn(), + }); + + return element; + } + + it('appends a sanitized, sized clone tagged as the preview', () => { + const target = withWidth(previewTarget(), 320); + const container = document.createElement('div'); + + renderDragPreview({ previewTarget: target, sourceElement: target, container }); + + const preview = container.firstElementChild as HTMLElement; + + expect(preview).not.toBe(target); + expect(preview.tagName).toEqual('ARTICLE'); + expect(preview.style.width).toEqual('320px'); + expect(preview.hasAttribute('data-preview')).toBe(true); + expect(preview.hasAttribute('data-controller')).toBe(false); + }); + + it('leaves the width unset when the preview target has no measured width', () => { + const target = withWidth(previewTarget(), 0); + const container = document.createElement('div'); + + renderDragPreview({ previewTarget: target, sourceElement: target, container }); + + const preview = container.firstElementChild as HTMLElement; + + expect(preview.style.width).toEqual(''); + }); + + it('copies the originating Box density variant onto the container', () => { + const box = document.createElement('div'); + box.className = 'Box Box--condensed'; + const target = withWidth(previewTarget(), 320); + box.appendChild(target); + + const container = document.createElement('div'); + + renderDragPreview({ previewTarget: target, sourceElement: target, container }); + + expect(container.classList.contains('Box--condensed')).toBe(true); + expect(container.classList.contains('Box--spacious')).toBe(false); + }); + + it('adds no density class when the Box carries none', () => { + const box = document.createElement('div'); + box.className = 'Box'; + const target = withWidth(previewTarget(), 320); + box.appendChild(target); + + const container = document.createElement('div'); + + renderDragPreview({ previewTarget: target, sourceElement: target, container }); + + expect(container.classList.contains('Box--condensed')).toBe(false); + expect(container.classList.contains('Box--spacious')).toBe(false); + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/preview.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/preview.ts new file mode 100644 index 000000000000..164443deca12 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/preview.ts @@ -0,0 +1,99 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +// Builds the custom native drag preview for a sortable item: a sanitised clone +// of the item's preview target, sized to match and carrying the originating +// Box's density so its card styling survives being mounted outside the Box. + +// Attributes stripped from the cloned drag preview so it carries no behaviour or +// stale interaction state. The dynamic `data-*--*-target` attributes are removed +// separately in sanitizePreview. +const PREVIEW_STRIPPED_ATTRIBUTES = [ + 'data-controller', + 'data-action', + 'data-dragging', + 'data-drop-position', + 'data-drop-position-owner', + 'data-selected', + 'aria-current', + 'aria-describedby', + 'aria-disabled', + 'aria-roledescription', +] as const; + +// Box density variant classes copied onto the drag-preview container. The preview is +// mounted outside the originating Box, so variant-scoped card styles (e.g. +// `.Box--condensed .Box-card`) would not apply to it otherwise. +const BOX_DENSITY_VARIANT_CLASSES = ['Box--condensed', 'Box--spacious'] as const; + +export function renderDragPreview({ + previewTarget, + sourceElement, + container, +}:{ + previewTarget:HTMLElement; + sourceElement:HTMLElement; + container:HTMLElement; +}):void { + const previewWidth = previewTarget.getBoundingClientRect().width; + const preview = previewTarget.cloneNode(true) as HTMLElement; + + sanitizePreview(preview); + preview.setAttribute('data-preview', ''); + + if (previewWidth > 0) { + preview.style.width = `${previewWidth}px`; + } + + const box = sourceElement.closest('.Box'); + + BOX_DENSITY_VARIANT_CLASSES.forEach((variant) => { + if (box?.classList.contains(variant)) { + container.classList.add(variant); + } + }); + + container.append(preview); +} + +export function sanitizePreview(element:HTMLElement):void { + // Avoid side effects from custom elements (e.g. Primer include-fragment) in the cloned preview. + element.querySelectorAll('include-fragment').forEach((fragment) => fragment.remove()); + + const nodes = [element, ...Array.from(element.querySelectorAll('*'))]; + + for (const node of nodes) { + PREVIEW_STRIPPED_ATTRIBUTES.forEach((attribute) => node.removeAttribute(attribute)); + + for (const attribute of Array.from(node.attributes)) { + if (/^data-.+--.+-target$/.test(attribute.name)) { + node.removeAttribute(attribute.name); + } + } + } +} From 9d6117af7082e04d63e0c6bc0c7b713e60c937d6 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 22:05:17 +0100 Subject: [PATCH 097/105] Add, integrate stimulus-elements Declarative, controller-scoped element lookups for Stimulus. Based on article by @marcoroth: https://marcoroth.dev/posts/supercharge-your-stimulus-controllers-with-custom-apis --- frontend/package-lock.json | 10 ++++++++++ frontend/package.json | 1 + frontend/src/stimulus/setup.ts | 3 +++ 3 files changed, 14 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1c4ca3cb9f01..5b9cbe331c81 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -63,6 +63,7 @@ "@openproject/octicons-angular": "^19.35.0", "@openproject/primer-view-components": "^0.89.1", "@openproject/reactivestates": "^3.0.1", + "@openproject/stimulus-elements": "^0.2.0", "@primer/css": "^22.1.0", "@primer/live-region-element": "^0.8.0", "@primer/primitives": "^11.5.1", @@ -5599,6 +5600,15 @@ "rxjs": "^7.8.0" } }, + "node_modules/@openproject/stimulus-elements": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@openproject/stimulus-elements/-/stimulus-elements-0.2.0.tgz", + "integrity": "sha512-EYVGMmgr/CrQOsweI7goVNaGs07fo4puxzcbACZuQoWmyvL5f2xc1RpU6CMx0ow/BxG5KDsSO+XmgHMWYzl7/A==", + "license": "MIT", + "peerDependencies": { + "@hotwired/stimulus": "^3.2" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4612b44fd26e..67d6309df492 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -108,6 +108,7 @@ "@openproject/octicons-angular": "^19.35.0", "@openproject/primer-view-components": "^0.89.1", "@openproject/reactivestates": "^3.0.1", + "@openproject/stimulus-elements": "^0.2.0", "@primer/css": "^22.1.0", "@primer/live-region-element": "^0.8.0", "@primer/primitives": "^11.5.1", diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index a1da451ec20a..51f930e958c5 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -49,6 +49,7 @@ import OpZenModeController from 'core-stimulus/controllers/zen-mode.controller'; import CheckAllController from 'core-stimulus/controllers/check-all.controller'; import CheckableController from 'core-stimulus/controllers/checkable.controller'; import ExpandableTextController from 'core-stimulus/controllers/expandable-text.controller'; +import { installElements } from '@openproject/stimulus-elements'; declare global { interface Window { @@ -104,6 +105,8 @@ OpenProjectStimulusApplication.preregister('header-project-select', HeaderProjec OpenProjectStimulusApplication.preregister('checkable', CheckableController); OpenProjectStimulusApplication.preregister('expandable-text', ExpandableTextController); +installElements(); + const instance = OpenProjectStimulusApplication.start(); window.Stimulus = instance; From 2824dac10d8b59ff99e0f1f60d88c47f0445561f Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 22:54:40 +0100 Subject: [PATCH 098/105] Carry the rows container on the list payload The list controller resolves its rows container via a stimulus-elements `:scope > ul` lookup (falling back to its own element) and carries it on `SortableListData`, so the drop handlers can later reorder rows without re-deriving the container. Installs the elements blessing in the test setup, matching the production bootstrap. --- .../sortable-lists/drag-and-drop.spec.ts | 16 ++++++++++++++ .../dynamic/sortable-lists/drag-and-drop.ts | 7 +++++++ .../sortable-lists/list.controller.spec.ts | 21 +++++++++++++++++++ .../dynamic/sortable-lists/list.controller.ts | 15 +++++++++++++ frontend/src/test-setup.ts | 6 ++++++ 5 files changed, 65 insertions(+) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts index c1b1b50363bd..cfdd0b20210a 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -146,6 +146,22 @@ describe('sortable lists drag and drop helpers', () => { }); }); + describe('sortableListData', () => { + it('carries the rows container on the list payload when provided', () => { + const container = document.createElement('ul'); + const data = sortableListData({ type: 'sprint', listId: '7', rowsContainer: container }); + + expect(data.rowsContainer).toBe(container); + expect(isSortableListData(data)).toBe(true); + }); + + it('defaults the list payload rows container to null', () => { + const data = sortableListData({ type: 'sprint', listId: '7' }); + + expect(data.rowsContainer).toBeNull(); + }); + }); + describe('acceptsSortableItemType', () => { it('allows drops when the controller has no accepted type filter', () => { expect(acceptsSortableItemType({ acceptedType: null, type: 'work_package' })).toBe(true); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index 19d1e29583d1..42405571fb30 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -59,6 +59,10 @@ export interface SortableListData extends Record { listId:string|null; // Where a list-only drop (header or empty space, not over an item) lands. dropPosition:SortableListDropPosition; + // The element whose direct children are the list's rows, resolved by the list + // controller and carried here so the drop handlers reorder rows without + // re-deriving it. Null means "fall back to the list element". + rowsContainer:HTMLElement|null; } // Implemented by the sortable-lists root controller and handed to list/item @@ -113,16 +117,19 @@ export function sortableListData({ type, listId, dropPosition = 'end', + rowsContainer = null, }:{ type:string; listId:string|null; dropPosition?:SortableListDropPosition; + rowsContainer?:HTMLElement|null; }):SortableListData { return { [sortableListDataKey]: true, type, listId, dropPosition, + rowsContainer, }; } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts index d1d93dc98272..147c7a8d1cc9 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts @@ -136,6 +136,27 @@ describe('Sortable lists list controller', () => { .toEqual(expect.objectContaining({ type: 'sprint', listId: '7' })); }); + it('resolves the child
                                                        as the rows container on the payload', async () => { + fixture.innerHTML = ` +
                                                        +
                                                          +
                                                          + `; + const list = fixture.querySelector('[data-controller~="sortable-lists--list"]')!; + await ctx.nextFrame(); + const rows = list.querySelector('ul')!; + + expect(dropTargetOptionsFor(list)?.getData?.({ element: list, input: {} as never, source: source(null) })) + .toEqual(expect.objectContaining({ rowsContainer: rows })); + }); + + it('falls back to the list element as the rows container when there is no child
                                                            ', async () => { + const { list } = await connectedListFor({ type: 'sprint', id: '7' }); + + expect(dropTargetOptionsFor(list)?.getData?.({ element: list, input: {} as never, source: source(null) })) + .toEqual(expect.objectContaining({ rowsContainer: list })); + }); + it('defaults its list-only drop position to end', async () => { const { list } = await connectedListFor({ type: 'sprint', id: '7' }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts index df790b9c7711..4596d982f509 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts @@ -50,12 +50,19 @@ export default class ListController extends Controller implements R dropPosition: { type: String, default: 'end' }, }; + static elements = { rowsContainer: ':scope > ul' }; + declare readonly typeValue:string; declare readonly hasTypeValue:boolean; declare readonly idValue:string; declare readonly hasIdValue:boolean; declare readonly dropPositionValue:string; + // Provided by the stimulus-elements blessing, declared manually in the same + // style as the controller's values/targets. + declare readonly rowsContainerElement:HTMLElement|null; + declare readonly hasRowsContainerElement:boolean; + private root?:SortableListsRoot; private cleanupFn?:CleanupFn; @@ -116,11 +123,19 @@ export default class ListController extends Controller implements R return dropPositions.has(this.dropPositionValue) ? this.dropPositionValue as SortableListDropPosition : 'end'; } + // Rows sit inside a child rows container (the Box list's
                                                              ). Lists that + // render rows directly under their own element have no such child, so fall + // back to the list element itself. + private get rowsContainer():HTMLElement { + return this.hasRowsContainerElement ? this.rowsContainerElement! : this.element; + } + private get listData():SortableListData { return sortableListData({ type: this.typeValue, listId: this.hasIdValue ? this.idValue : null, dropPosition: this.dropPosition, + rowsContainer: this.rowsContainer, }); } diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index 6722f6db937f..a4f59a24dece 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -2,6 +2,12 @@ import { I18n } from 'i18n-js'; import '@testing-library/jest-dom/vitest'; import { afterEach, vi } from 'vitest'; import { registerDialogStreamAction } from 'core-turbo/dialog-stream-action'; +import { installElements } from '@openproject/stimulus-elements'; + +// Blesses every controller registered afterwards with `static elements` +// accessors, mirroring the production bootstrap in stimulus/setup.ts. Must run +// before any controller is registered, so it lives in this global setup file. +installElements(); registerDialogStreamAction(); From 6efcf64afdadd2aa28c640000f6dd05477ae610e Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 23:05:03 +0100 Subject: [PATCH 099/105] Resolve sortable rows from the rows container Treats a list's rows as the direct children of its resolved rows container instead of matching `
                                                                `/`
                                                              • ` selectors. `resolveDropIntent` threads the payload's `rowsContainer` (falling back to the list element) through row resolution and the optimistic reorder, and `rowOf` replaces the `.closest('li')` row lookup. Rows may now be any element. --- .../dynamic/sortable-lists.controller.ts | 2 +- .../sortable-lists/drag-and-drop.spec.ts | 128 ++++++++++++++---- .../dynamic/sortable-lists/drag-and-drop.ts | 18 ++- .../dynamic/sortable-lists/list-dom.spec.ts | 50 ++++--- .../dynamic/sortable-lists/list-dom.ts | 64 +++++---- 5 files changed, 186 insertions(+), 76 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index a80ba7256693..859042397384 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -161,7 +161,7 @@ export default class SortableListsController extends Controller imp // back to where it started. const rows = [sourceRow]; const rollback = captureRowPositions(rows); - reorderRows({ rows, list: intent.listElement, previousItemId: intent.previousItemId }); + reorderRows({ rows, rowsContainer: intent.rowsContainer, previousItemId: intent.previousItemId }); const result = await this.moveItem({ listData: intent.listData, diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts index cfdd0b20210a..c89eb73181fc 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -60,6 +60,12 @@ describe('sortable lists drag and drop helpers', () => { return row; } + function divRow(id:string):HTMLDivElement { + const row = document.createElement('div'); + row.setAttribute('data-sortable-lists--item-id-value', id); + return row; + } + function input({ clientX = 10, clientY = 10 } = {}) { return { altKey: false, @@ -148,10 +154,10 @@ describe('sortable lists drag and drop helpers', () => { describe('sortableListData', () => { it('carries the rows container on the list payload when provided', () => { - const container = document.createElement('ul'); - const data = sortableListData({ type: 'sprint', listId: '7', rowsContainer: container }); + const rowsContainer = document.createElement('ul'); + const data = sortableListData({ type: 'sprint', listId: '7', rowsContainer: rowsContainer }); - expect(data.rowsContainer).toBe(container); + expect(data.rowsContainer).toBe(rowsContainer); expect(isSortableListData(data)).toBe(true); }); @@ -196,76 +202,87 @@ describe('sortable lists drag and drop helpers', () => { describe('resolvePreviousSortableItemId', () => { it('uses the target item as previous item when dropping on the bottom edge', () => { - const target = itemRow('3').querySelector('article')!; + const rowsContainer = document.createElement('ul'); + const targetRow = itemRow('3'); + const target = targetRow.querySelector('article')!; - expect(resolvePreviousSortableItemId({ sourceItemId: '1', targetItem: target, closestEdge: 'bottom' })).toEqual('3'); + rowsContainer.append(targetRow); + + expect(resolvePreviousSortableItemId({ sourceItemId: '1', targetItem: target, closestEdge: 'bottom', rowsContainer })).toEqual('3'); }); it('uses the row item as previous item when the drop target is the row', () => { - const target = itemRow('3'); + const rowsContainer = document.createElement('ul'); + const targetRow = itemRow('3'); - expect(resolvePreviousSortableItemId({ sourceItemId: '1', targetItem: target, closestEdge: 'bottom' })).toEqual('3'); + rowsContainer.append(targetRow); + + expect(resolvePreviousSortableItemId({ sourceItemId: '1', targetItem: targetRow, closestEdge: 'bottom', rowsContainer })).toEqual('3'); }); it('uses the previous row item when dropping on the top edge', () => { - const list = document.createElement('ul'); + const rowsContainer = document.createElement('ul'); const first = itemRow('1'); const targetRow = itemRow('3'); const target = targetRow.querySelector('article')!; - list.append(first, targetRow); + rowsContainer.append(first, targetRow); - expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: 'top' })).toEqual('1'); + expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: 'top', rowsContainer })).toEqual('1'); }); it('uses the previous row item when dropping on the top edge of a row target', () => { - const list = document.createElement('ul'); + const rowsContainer = document.createElement('ul'); const first = itemRow('1'); const targetRow = itemRow('3'); - list.append(first, targetRow); + rowsContainer.append(first, targetRow); - expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: targetRow, closestEdge: 'top' })).toEqual('1'); + expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: targetRow, closestEdge: 'top', rowsContainer })).toEqual('1'); }); it('treats a missing closest edge as dropping before the target item', () => { - const list = document.createElement('ul'); + const rowsContainer = document.createElement('ul'); const first = itemRow('1'); const targetRow = itemRow('3'); const target = targetRow.querySelector('article')!; - list.append(first, targetRow); + rowsContainer.append(first, targetRow); - expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: null })).toEqual('1'); + expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: null, rowsContainer })).toEqual('1'); }); it('uses a truncation marker when dropping before a tail item', () => { - const list = document.createElement('ul'); + const rowsContainer = document.createElement('ul'); const first = itemRow('1'); const targetRow = itemRow('6'); const target = targetRow.querySelector('article')!; - list.append(first, showMoreRow('5'), targetRow); + rowsContainer.append(first, showMoreRow('5'), targetRow); - expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: 'top' })).toEqual('5'); + expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: 'top', rowsContainer })).toEqual('5'); }); it('skips the source item and uses a preceding truncation marker when resolving the previous item', () => { - const list = document.createElement('ul'); + const rowsContainer = document.createElement('ul'); const first = itemRow('1'); - const source = itemRow('2'); + const sourceRow = itemRow('2'); const targetRow = itemRow('3'); const target = targetRow.querySelector('article')!; - list.append(first, showMoreRow(), source, targetRow); + rowsContainer.append(first, showMoreRow(), sourceRow, targetRow); - expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: 'top' })).toEqual('hidden-item'); + expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: 'top', rowsContainer })).toEqual('hidden-item'); }); it('returns null when dropping before the first item', () => { - const target = itemRow('1').querySelector('article')!; + const rowsContainer = document.createElement('ul'); + const targetRow = itemRow('1'); + const target = targetRow.querySelector('article')!; + + rowsContainer.append(targetRow); - expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: 'top' })).toBeNull(); + expect(resolvePreviousSortableItemId({ sourceItemId: '2', targetItem: target, closestEdge: 'top', rowsContainer })).toBeNull(); }); }); @@ -473,5 +490,66 @@ describe('sortable lists drag and drop helpers', () => { expect(intent).toBeNull(); }); + + it('carries the resolved rows container on the intent', () => { + const { root, list } = buildList(); + const source = itemRow('1'); + const target = itemRow('2'); + + list.append(source, target); + document.body.appendChild(root); + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(rect()); + + const data = attachClosestEdge(sortableItemData({ type: 'work_package', itemId: '2' }), { + element: target, + input: input({ clientY: 90 }), + allowedEdges: ['top', 'bottom'], + }); + + const intent = resolveDropIntent({ + location: dropLocation({ + dropTargets: [ + { data, element: target }, + { data: sortableListData({ type: 'backlog_bucket', listId: '7' }), element: list }, + ], + }), + root, + sourceElement: source, + sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), + }); + + // No rows container on the payload falls back to the list element. + expect(intent?.rowsContainer).toBe(list); + }); + + it('resolves rows from the payload rows container when it differs from the list element', () => { + const root = document.createElement('div'); + const listElement = document.createElement('div'); + const rowsContainer = document.createElement('section'); + const sourceList = document.createElement('ul'); + const source = itemRow('1'); + + listElement.setAttribute('data-controller', 'sortable-lists--list'); + rowsContainer.append(divRow('4'), divRow('5')); + listElement.append(rowsContainer); + sourceList.setAttribute('data-controller', 'sortable-lists--list'); + sourceList.append(source); + root.append(sourceList, listElement); + + const intent = resolveDropIntent({ + location: dropLocation({ + dropTargets: [{ + data: sortableListData({ type: 'backlog_bucket', listId: '7', rowsContainer }), + element: listElement, + }], + }), + root, + sourceElement: source, + sourceData: sortableItemData({ type: 'work_package', itemId: '1' }), + }); + + expect(intent?.rowsContainer).toBe(rowsContainer); + expect(intent?.previousItemId).toEqual('5'); + }); }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index 42405571fb30..ddc5871e529f 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -36,6 +36,7 @@ import { resolveItemId, resolveListAppendPreviousItemId, resolvePreviousItemId, + rowOf, sortableListSelector, } from './list-dom'; @@ -190,10 +191,12 @@ export function resolvePreviousSortableItemId({ sourceItemId, targetItem, closestEdge, + rowsContainer, }:{ sourceItemId:string; targetItem:HTMLElement; closestEdge:Edge|null; + rowsContainer:Element; }):string|null { const targetItemElement = resolveItemElement(targetItem); const targetItemId = targetItemElement ? resolveItemId(targetItemElement) : null; @@ -202,7 +205,7 @@ export function resolvePreviousSortableItemId({ return targetItemId; } - const targetRow = (targetItemElement ?? targetItem).closest('li'); + const targetRow = rowOf(rowsContainer, targetItemElement ?? targetItem); let row = targetRow?.previousElementSibling ?? null; while (row) { @@ -222,24 +225,25 @@ export function resolvePreviousSortableItemId({ // (null previous item), 'end' appends after the last. function resolveListOnlyPreviousItemId({ sourceItemId, - list, + rowsContainer, dropPosition, }:{ sourceItemId:string; - list:HTMLElement; + rowsContainer:HTMLElement; dropPosition:SortableListDropPosition; }):string|null { if (dropPosition === 'start') { return null; } - return resolveListAppendPreviousItemId({ sourceItemId, list }); + return resolveListAppendPreviousItemId({ sourceItemId, rowsContainer }); } export interface DropIntent { listElement:HTMLElement; listData:SortableListData; previousItemId:string|null; + rowsContainer:HTMLElement; } // Resolve where a dropped item should land: the list it was dropped into and @@ -273,6 +277,7 @@ export function resolveDropIntent({ const listElement = targetList.element; const listData = targetList.data; + const rowsContainer = listData.rowsContainer ?? listElement; if (!targetItem && isSourceListTarget({ sourceElement, targetElement: listElement })) { return null; @@ -283,12 +288,13 @@ export function resolveDropIntent({ sourceItemId: sourceData.itemId, targetItem: targetItem.element, closestEdge: extractClosestEdge(targetItem.data), + rowsContainer, }) : resolveListOnlyPreviousItemId({ sourceItemId: sourceData.itemId, - list: listElement, + rowsContainer, dropPosition: listData.dropPosition, }); - return { listElement, listData, previousItemId }; + return { listElement, listData, previousItemId, rowsContainer }; } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts index 7ecd0ec8a6c9..8670db3e9bf1 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts @@ -31,6 +31,7 @@ import { reorderRows, resolveListAppendPreviousItemId, restoreRowPositions, + rowOf, } from './list-dom'; describe('sortable lists DOM helpers', () => { @@ -44,6 +45,12 @@ describe('sortable lists DOM helpers', () => { return row; } + function divItemRow(id:string):HTMLDivElement { + const row = document.createElement('div'); + row.setAttribute('data-sortable-lists--item-id-value', id); + return row; + } + function showMoreRow(previousItemId = 'hidden-item'):HTMLLIElement { const row = document.createElement('li'); @@ -71,7 +78,7 @@ describe('sortable lists DOM helpers', () => { list.append(itemRow('1'), showMoreRow(), itemRow('2'), itemRow('3')); - expect(resolveListAppendPreviousItemId({ sourceItemId: '3', list })).toEqual('2'); + expect(resolveListAppendPreviousItemId({ sourceItemId: '3', rowsContainer: list })).toEqual('2'); }); it('returns null when the list has no other items', () => { @@ -79,7 +86,7 @@ describe('sortable lists DOM helpers', () => { list.append(itemRow('1')); - expect(resolveListAppendPreviousItemId({ sourceItemId: '1', list })).toBeNull(); + expect(resolveListAppendPreviousItemId({ sourceItemId: '1', rowsContainer: list })).toBeNull(); }); }); @@ -89,7 +96,7 @@ describe('sortable lists DOM helpers', () => { const [one, two, three] = ['1', '2', '3'].map(itemRow); list.append(one, two, three); - reorderRows({ rows: [one], list, previousItemId: '2' }); + reorderRows({ rows: [one], rowsContainer: list, previousItemId: '2' }); expect(itemIdOrder(list)).toEqual(['2', '1', '3']); }); @@ -99,24 +106,31 @@ describe('sortable lists DOM helpers', () => { const [one, two, three] = ['1', '2', '3'].map(itemRow); list.append(one, two, three); - reorderRows({ rows: [three], list, previousItemId: null }); + reorderRows({ rows: [three], rowsContainer: list, previousItemId: null }); expect(itemIdOrder(list)).toEqual(['3', '1', '2']); }); - it('keeps a top-of-list move inside a nested list element instead of escaping it', () => { - const list = document.createElement('div'); - const inner = document.createElement('ul'); - const [one, two, three] = ['1', '2', '3'].map(itemRow); + it('reorders rows that are neither
                                                                  nor
                                                                • under any container element', () => { + const rowsContainer = document.createElement('div'); + const [one, two, three] = ['1', '2', '3'].map(divItemRow); - list.setAttribute('data-sortable-lists-target', 'list'); - inner.append(one, two, three); - list.append(inner); + rowsContainer.append(one, two, three); + reorderRows({ rows: [three], rowsContainer, previousItemId: '1' }); - reorderRows({ rows: [three], list, previousItemId: null }); + expect(itemIdOrder(rowsContainer)).toEqual(['1', '3', '2']); + }); - expect(three.parentElement).toBe(inner); - expect(itemIdOrder(list)).toEqual(['3', '1', '2']); + it('resolves the row that holds a nested element via rowOf', () => { + const rowsContainer = document.createElement('ul'); + const row = itemRow('9'); + const inner = row.querySelector('article')!; + + rowsContainer.append(row); + + expect(rowOf(rowsContainer, inner)).toBe(row); + expect(rowOf(rowsContainer, row)).toBe(row); + expect(rowOf(rowsContainer, document.createElement('div'))).toBeNull(); }); it('inserts a moved group after the anchor preserving their order', () => { @@ -124,7 +138,7 @@ describe('sortable lists DOM helpers', () => { const [one, two, three, four] = ['1', '2', '3', '4'].map(itemRow); list.append(one, two, three, four); - reorderRows({ rows: [three, four], list, previousItemId: '1' }); + reorderRows({ rows: [three, four], rowsContainer: list, previousItemId: '1' }); expect(itemIdOrder(list)).toEqual(['1', '3', '4', '2']); }); @@ -135,7 +149,7 @@ describe('sortable lists DOM helpers', () => { const marker = showMoreRow('hidden'); list.append(three, one, marker, two); - reorderRows({ rows: [three], list, previousItemId: 'hidden' }); + reorderRows({ rows: [three], rowsContainer: list, previousItemId: 'hidden' }); expect(three.previousElementSibling).toBe(marker); expect(itemIdOrder(list)).toEqual(['1', '3', '2']); @@ -157,7 +171,7 @@ describe('sortable lists DOM helpers', () => { document.body.append(list); const snapshot = captureRowPositions([three]); - reorderRows({ rows: [three], list, previousItemId: null }); + reorderRows({ rows: [three], rowsContainer: list, previousItemId: null }); expect(itemIdOrder(list)).toEqual(['3', '1', '2']); restoreRowPositions(snapshot); @@ -172,7 +186,7 @@ describe('sortable lists DOM helpers', () => { document.body.append(list); const snapshot = captureRowPositions([two, three]); - reorderRows({ rows: [two, three], list, previousItemId: '4' }); + reorderRows({ rows: [two, three], rowsContainer: list, previousItemId: '4' }); expect(itemIdOrder(list)).toEqual(['1', '4', '2', '3']); restoreRowPositions(snapshot); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts index a098c8eb552d..3bf1171ccbe8 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts @@ -32,23 +32,36 @@ // sparse non-item rows may expose data-sortable-lists-prev-item-id. // // This module holds the drag-and-drop-agnostic half of that contract: reading -// items and rows out of the DOM and moving rows around. The Pragmatic DnD -// payloads built on top of it live in drag-and-drop.ts. +// rows out of a list's rows container (rows are its direct children, whatever +// their tag) and moving them around. The Pragmatic DnD payloads built on top of +// it live in drag-and-drop.ts. export const sortableListsMovingAttribute = 'data-sortable-lists-moving'; export const sortableListsRootSelector = '[data-controller~="sortable-lists"]'; export const sortableItemSelector = '[data-sortable-lists--item-id-value]'; export const sortableListSelector = '[data-controller~="sortable-lists--list"]'; export const sortablePreviousItemIdAttribute = 'data-sortable-lists-prev-item-id'; -// Rows can sit directly under the list element or inside a nested
                                                                    . -const listRowSelector = ':scope > li, :scope > ul > li'; +// Rows are the direct children of the list's resolved rows container. The +// rows container itself (a nested
                                                                      , the list element, ...) is decided by the list +// controller; this module treats any direct child as a row. +function listRows(rowsContainer:Element):Element[] { + return Array.from(rowsContainer.children); +} -function listRows(list:Element):Element[] { - return Array.from(list.querySelectorAll(listRowSelector)); +function firstListRow(rowsContainer:Element):Element|null { + return rowsContainer.firstElementChild; } -function firstListRow(list:Element):Element|null { - return list.querySelector(listRowSelector); +// The row (direct child of the rows container) that holds the given element, or null +// when the element is not inside a row of this rows container. +export function rowOf(rowsContainer:Element, element:Element):HTMLElement|null { + let current:Element|null = element; + + while (current && current.parentElement !== rowsContainer) { + current = current.parentElement; + } + + return current instanceof HTMLElement ? current : null; } export function resolveItemId(element:Element):string|null { @@ -81,22 +94,22 @@ export function resolvePreviousItemId(element:Element):string|null { // on data-sortable-lists-prev-item-id rather than exposing an item element. // Anchor on that marker so the row lands next to the collapsed block instead // of jumping to the top. -function resolveAnchorRow(list:HTMLElement, previousItemId:string):HTMLElement|null { +function resolveAnchorRow(rowsContainer:HTMLElement, previousItemId:string):HTMLElement|null { const escaped = CSS.escape(previousItemId); - const anchor = list.querySelector(`[data-sortable-lists--item-id-value="${escaped}"]`) - ?? list.querySelector(`[${sortablePreviousItemIdAttribute}="${escaped}"]`); + const anchor = rowsContainer.querySelector(`[data-sortable-lists--item-id-value="${escaped}"]`) + ?? rowsContainer.querySelector(`[${sortablePreviousItemIdAttribute}="${escaped}"]`); - return anchor?.closest('li') ?? null; + return anchor ? rowOf(rowsContainer, anchor) : null; } export function resolveListAppendPreviousItemId({ sourceItemId, - list, + rowsContainer, }:{ sourceItemId:string; - list:Element; + rowsContainer:Element; }):string|null { - const rows = listRows(list).reverse(); + const rows = listRows(rowsContainer).reverse(); for (const row of rows) { const itemId = resolvePreviousItemId(row); @@ -141,39 +154,38 @@ export function restoreRowPositions(positions:RowPlacement[]):void { } // Optimistically move rows on the client without waiting for the server. -// `rows` are the moved
                                                                    • s in order (one today, the selected set once +// `rows` are the moved rows in order (one today, the selected set once // multi-item DnD lands); `previousItemId` of null means top of list. export function reorderRows({ rows, - list, + rowsContainer, previousItemId, }:{ rows:HTMLElement[]; - list:HTMLElement; + rowsContainer:HTMLElement; previousItemId:string|null; }):void { - let anchor:Element|null = previousItemId ? resolveAnchorRow(list, previousItemId) : null; + let anchor:Element|null = previousItemId ? resolveAnchorRow(rowsContainer, previousItemId) : null; for (const row of rows) { if (anchor) { anchor.after(row); } else { - insertAtListTop(list, row); + insertAtListTop(rowsContainer, row); } anchor = row; } } -// A plain list.prepend() would drop the row before a nested
                                                                        . Insert -// before the first existing row instead, keeping it in the same container as -// its siblings. -function insertAtListTop(list:HTMLElement, row:HTMLElement):void { - const firstRow = firstListRow(list); +// Insert before the first existing row, keeping the moved row among its +// siblings. An empty rows container simply receives the row. +function insertAtListTop(rowsContainer:HTMLElement, row:HTMLElement):void { + const firstRow = firstListRow(rowsContainer); if (firstRow && firstRow !== row) { firstRow.before(row); } else if (!firstRow) { - (list.querySelector(':scope > ul') ?? list).prepend(row); + rowsContainer.prepend(row); } } From 862c81e9241468bbd315ce3475623d44e9f9fa9f Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 6 Jul 2026 23:10:31 +0100 Subject: [PATCH 100/105] Document the structural rows vocabulary Adds `Row`, `Rows container` and `Structural row` to the frontend domain glossary and rewrites the sortable-lists DOM-shape contract, which no longer pins rows to `
                                                                      • `/`
                                                                          `. --- frontend/CONTEXT.md | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/frontend/CONTEXT.md b/frontend/CONTEXT.md index 352fa978feba..622adb6473d1 100644 --- a/frontend/CONTEXT.md +++ b/frontend/CONTEXT.md @@ -33,6 +33,18 @@ target** edge — carried by the list's `data-drop-container` attribute. An empty area that can accept a dragged object when there is no existing object to anchor a drop indicator. _Avoid_: Placeholder +**Row**: +A direct child of a **Rows container**. It may hold a **Sortable item** or be a +non-item marker (e.g. a truncated "show more" row carrying +`data-sortable-lists-prev-item-id`). Distinct from **Sortable item**, which is +the draggable payload — not every row is an item. + +**Rows container**: +The element whose direct children are a **Sortable list**'s **Rows**, resolved by +the list controller (`:scope > ul` for Backlogs BorderBox lists; the list element +itself when there is no such child). A structural term for the DOM parent of the +rows — not the interaction-sense "container" that is avoided for drop areas. + **Sortable item**: An object that can be repositioned by drag and drop within or between sortable lists. _Avoid_: Draggable item @@ -45,6 +57,10 @@ _Avoid_: Draggable type An ordered list whose sortable items can be repositioned by drag and drop. _Avoid_: Container +**Structural row**: +A **Row** identified purely by being a direct child of the **Rows container**, +independent of its tag. Successor to the fixed `
                                                                            `/`
                                                                          • ` row shape. + ## Relationships - A **Drag source** remains at the original location while the **Drag preview** follows the pointer. @@ -54,6 +70,8 @@ _Avoid_: Container - A **Sortable item** has a **Sortable item type**. - A **Sortable list** may accept only specific **Sortable item types**. - An active **Sortable list** shows a **Drop container** outline while a compatible **Sortable item** hovers it, distinct from the per-item **Drop indicator**. +- A **Sortable list** has a **Rows container** whose direct children are its + **Rows**; a **Row** may hold a **Sortable item**. ## Sortable lists wiring (consumer contract) @@ -94,10 +112,12 @@ indicator owner): - Target `handle` (optional): restricts the drag to a handle element. - Target `preview` (optional): the element cloned for the **Drag preview**. -**DOM shape (required):** rows are `
                                                                          • ` elements, sitting either directly under -the list element or inside a single nested `
                                                                              `. A non-item row (e.g. a -truncated "show more" marker) may carry `data-sortable-lists-prev-item-id` so -position resolution stays correct in sparse lists. +**DOM shape (required):** a list's **Rows** are the direct children of its +**Rows container** — the element the list controller resolves via its +`rowsContainer` element (`:scope > ul`, falling back to the list element). Rows +may be any tag. A non-item row (e.g. a truncated "show more" marker) may carry +`data-sortable-lists-prev-item-id` so position resolution stays correct in +sparse lists. **Move wire:** a successful drop PUTs `FormData` (`list_type`, `list_id`, `prev_id`) to the expanded move URL; the server replies with a turbo-stream. A @@ -117,3 +137,6 @@ the row back and toast. - "placeholder" was used for highlighted gaps — resolved: use **Drop indicator** unless full object-sized space is reserved. - "container" was used for drop areas — resolved: use **Drop target** for drag-and-drop interaction language. - "draggable type" was inherited from the generic drag-and-drop controller — resolved: use **Sortable item type**. +- "container" is avoided for drag-and-drop *interaction* language (drop areas) — + resolved: **Rows container** is a structural DOM term for the rows' parent and + is exempt from that rule. From b58afc8c8bad09a7b3f905505de741015de46e6d Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 7 Jul 2026 10:00:07 +0100 Subject: [PATCH 101/105] Flag the drag source row's residual li lookup --- .../stimulus/controllers/dynamic/sortable-lists.controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 859042397384..c94006b1c9c3 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -151,6 +151,10 @@ export default class SortableListsController extends Controller imp return; } + // The dragged source row is still resolved as an
                                                                            • , the one place the + // subsystem is not yet tag-agnostic: reaching its rows container structurally + // needs the source list (not the root) on the item payload. Backlogs rows are + //
                                                                            • , so this holds today; generalising it is a tracked follow-up. const sourceRow = source.element.closest('li'); if (!(sourceRow instanceof HTMLElement)) { return; From 527af96579ae51d5f843e4474143ae063ba95ef9 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 7 Jul 2026 14:19:23 +0100 Subject: [PATCH 102/105] Declare the accepted item type per sortable list The sortable-lists root declared one accepted item type shared by every list under it, so lists in one root could not accept different types. This blocks mixed boards and the project-attributes drag-and-drop work. Splits the old `canAccept` into a shared `isItemFromRoot` root-scope guard and a per-list single-token `acceptedType` check. The item drop target matches the dragged type against its own type (a same-type proxy needing no list lookup); the root sheds its `acceptedType` value and the now-dead `canAccept`/`acceptsSortableItemType` helpers. Backlogs renders the accepted type on each list (bucket, sprint, inbox) instead of once on the root frame. Behaviour is unchanged for backlogs, where every list accepts `work_package`; the accept rule is now per-list. The `connectRoot` object-topology is untouched. --- frontend/CONTEXT.md | 18 +++++----- .../dynamic/sortable-lists.controller.spec.ts | 21 +++++------- .../dynamic/sortable-lists.controller.ts | 9 ----- .../sortable-lists/drag-and-drop.spec.ts | 30 +++++++++++----- .../dynamic/sortable-lists/drag-and-drop.ts | 34 ++++++------------- .../sortable-lists/item.controller.spec.ts | 24 ++++++++++--- .../dynamic/sortable-lists/item.controller.ts | 19 ++++++----- .../sortable-lists/list.controller.spec.ts | 30 +++++++++++----- .../dynamic/sortable-lists/list.controller.ts | 20 +++++++++-- .../scrollable.controller.spec.ts | 2 +- .../backlogs/bucket_component.html.erb | 1 + .../backlogs/inbox_component.html.erb | 1 + .../backlogs/sprint_component.html.erb | 1 + .../work_package_card_list_component.rb | 3 +- .../app/views/backlogs/backlog/show.html.erb | 1 - .../backlogs/bucket_component_spec.rb | 1 + .../backlogs/inbox_component_spec.rb | 1 + .../backlogs/sprint_component_spec.rb | 1 + .../work_package_card_list_component_spec.rb | 3 +- .../spec/requests/backlogs/backlog_spec.rb | 1 - 20 files changed, 131 insertions(+), 90 deletions(-) diff --git a/frontend/CONTEXT.md b/frontend/CONTEXT.md index 622adb6473d1..3c29abd0a303 100644 --- a/frontend/CONTEXT.md +++ b/frontend/CONTEXT.md @@ -68,7 +68,7 @@ independent of its tag. Successor to the fixed `
                                                                                `/`
                                                                              • ` row shape. - A **Drop placeholder** reserves object-sized space at a candidate location. - An **Empty drop zone** may combine the affordances of a **Drop target**, **Drop indicator**, and **Drop placeholder**. - A **Sortable item** has a **Sortable item type**. -- A **Sortable list** may accept only specific **Sortable item types**. +- A **Sortable list** may accept only a specific **Sortable item type**. - An active **Sortable list** shows a **Drop container** outline while a compatible **Sortable item** hovers it, distinct from the per-item **Drop indicator**. - A **Sortable list** has a **Rows container** whose direct children are its **Rows**; a **Row** may hold a **Sortable item**. @@ -83,9 +83,6 @@ OpenProject features, not as a standalone library. **Root — `sortable-lists`** (the orchestrator; owns the move request, the drag monitor, and auto-scroll): -- `accepted-type-value` (optional): the one **Sortable item type** every list - under this root accepts. Root-scoped — there is one accepted type per root, - shared by all its lists. Omit to accept any type. - `move-url-template-value`: a URI template expanded with `{id}` (the moved item's id) to build the PUT URL. Without it, no move is persisted. - `allowed-axis-value` (default `vertical`), `max-scroll-speed-value` @@ -96,7 +93,11 @@ monitor, and auto-scroll): **List — `sortable-lists--list`** (a **Drop target** that shows a **Drop container** outline while active): -- `type-value` (**required** to be a drop target; a list without it is inert). +- `type-value` (**required**): the list's **Sortable item type** label, sent as + `list_type` on a successful move. +- `accepted-type-value` (**required** to be a drop target): the one **Sortable + item type** this list accepts. A list without it is inert; a list that sets + it but omits `type-value` warns and does not register. - `id-value` (optional): the list id sent as `list_id`. - `drop-position-value` (default `end`): where a drop that resolves to the list itself, rather than to an item edge, inserts — `start` files the item at the top, @@ -106,9 +107,10 @@ outline while active): indicator owner): - `id-value` (**required**): the item id; sent as the `{id}` URL segment. - `type-value` (**required**): the item's **Sortable item type**, matched - against the root's `accepted-type-value`. There is no default — an item that - omits id or type appears draggable but silently refuses every drop, so the - controller `console.warn`s on connect when either is empty. + against the list's `accepted-type-value` (an item accepts a same-type drop). + There is no default — an item that omits id or type appears draggable but + silently refuses every drop, so the controller `console.warn`s on connect + when either is empty. - Target `handle` (optional): restricts the drag to a handle element. - Target `preview` (optional): the element cloned for the **Drag preview**. diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 0146d696edb6..7cea1aab1ab8 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -109,21 +109,19 @@ describe('Sortable lists controller', () => { } function renderFixture({ - acceptedType = null, moveUrlTemplate = '/move/{id}', - }:{ acceptedType?:string|null; moveUrlTemplate?:string|null } = {}) { + }:{ moveUrlTemplate?:string|null } = {}) { fixture.innerHTML = `
                                                                                -
                                                                                  -
                                                                                    +
                                                                                      +
                                                                                        `; @@ -239,24 +237,22 @@ describe('Sortable lists controller', () => {
                                                                                        -
                                                                                          +
                                                                                        -
                                                                                          +
                                                                                          @@ -326,13 +322,12 @@ describe('Sortable lists controller', () => { id="backlogs-list" src="/projects/demo/backlogs/backlog?bucket_ids%5B%5D=1&bucket_ids%5B%5D=inbox&sprint_ids%5B%5D=2" data-controller="sortable-lists" - data-sortable-lists-accepted-type-value="work_package" data-sortable-lists-move-url-template-value="/projects/demo/backlogs/work_packages/{id}/move" data-sortable-lists-sortable-lists--list-outlet="#backlogs-list [data-controller~='sortable-lists--list']" data-sortable-lists-sortable-lists--item-outlet="#backlogs-list [data-controller~='sortable-lists--item']" > -
                                                                                            -
                                                                                              +
                                                                                                +
                                                                                                  `; @@ -536,7 +531,7 @@ describe('Sortable lists controller', () => { it('hands the root reference to connected list, item, and scrollable controllers', async () => { const { root, sourceList, scrollable, firstSourceItem, - } = renderFixture({ acceptedType: 'work_package' }); + } = renderFixture(); await ctx.nextFrame(); // Outlet connections happen after the controller frame; a second frame // ensures the hand-over callbacks have fired. diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index c94006b1c9c3..4c9ffb511d02 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -59,15 +59,12 @@ export default class SortableListsController extends Controller imp static outlets = ['sortable-lists--list', 'sortable-lists--item', 'sortable-lists--scrollable']; static values = { - acceptedType: String, moveUrlTemplate: String, }; declare readonly sortableListsListOutlets:import('./sortable-lists/list.controller').default[]; declare readonly sortableListsItemOutlets:RootAwareChild[]; - declare readonly acceptedTypeValue:string; - declare readonly hasAcceptedTypeValue:boolean; declare readonly moveUrlTemplateValue:string; declare readonly hasMoveUrlTemplateValue:boolean; @@ -113,12 +110,6 @@ export default class SortableListsController extends Controller imp scrollable.disconnectRoot(); } - get acceptedType():string|null { - // The accepted type is scoped to this controller instance, so every list - // outlet inside one sortable-lists root accepts the same sortable item type. - return this.hasAcceptedTypeValue ? this.acceptedTypeValue : null; - } - get moving():boolean { return this.element.hasAttribute(sortableListsMovingAttribute); } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts index c89eb73181fc..849b6076f5c5 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts @@ -31,8 +31,8 @@ import { vi } from 'vitest'; import { attachClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { type DragLocationHistory } from '@atlaskit/pragmatic-drag-and-drop/types'; import { - acceptsSortableItemType, buildMoveFormData, + isItemFromRoot, isSortableItemData, isSortableListData, resolveDropIntent, @@ -168,17 +168,31 @@ describe('sortable lists drag and drop helpers', () => { }); }); - describe('acceptsSortableItemType', () => { - it('allows drops when the controller has no accepted type filter', () => { - expect(acceptsSortableItemType({ acceptedType: null, type: 'work_package' })).toBe(true); + describe('isItemFromRoot', () => { + const root = document.createElement('div'); + + it('accepts a sortable item whose rootElement is this root', () => { + const data = sortableItemData({ itemId: '1', type: 'work_package', rootElement: root }); + expect(isItemFromRoot(root, data)).toBe(true); + }); + + it('rejects a sortable item from another root', () => { + const data = sortableItemData({ itemId: '1', type: 'work_package', rootElement: document.createElement('div') }); + expect(isItemFromRoot(root, data)).toBe(false); }); - it('allows drops when the source type matches the accepted type', () => { - expect(acceptsSortableItemType({ acceptedType: 'work_package', type: 'work_package' })).toBe(true); + it('rejects a sortable item with no root reference', () => { + const data = sortableItemData({ itemId: '1', type: 'work_package', rootElement: null }); + expect(isItemFromRoot(root, data)).toBe(false); + }); + + it('rejects a null root element', () => { + const data = sortableItemData({ itemId: '1', type: 'work_package', rootElement: root }); + expect(isItemFromRoot(null, data)).toBe(false); }); - it('rejects drops when the source type does not match the accepted type', () => { - expect(acceptsSortableItemType({ acceptedType: 'work_package', type: 'meeting_agenda_item' })).toBe(false); + it('rejects a non-item payload', () => { + expect(isItemFromRoot(root, { foo: 'bar' })).toBe(false); }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index ddc5871e529f..8b39fd86d8ae 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -72,7 +72,6 @@ export interface SortableListData extends Record { export interface SortableListsRoot { readonly element:HTMLElement; readonly moving:boolean; - readonly acceptedType:string|null; } // Implemented by the list and item controllers so the root can hand them its @@ -152,29 +151,16 @@ export function buildMoveFormData({ return data; } -export function acceptsSortableItemType({ - acceptedType, - type, -}:{ - acceptedType:string|null; - type:string; -}):boolean { - return acceptedType === null || acceptedType === type; -} - -// The drop rule shared by list and item drop targets: the payload must be a -// sortable item belonging to this same root and of an accepted type. Item -// targets additionally exclude themselves before calling this. -export function canAccept(root:SortableListsRoot, data:Record):boolean { - if (!isSortableItemData(data)) { - return false; - } - - if (data.rootElement == null || data.rootElement !== root.element) { - return false; - } - - return acceptsSortableItemType({ acceptedType: root.acceptedType, type: data.type }); +// The shared root-scoping rule: the payload must be a sortable item created by +// an item controller wired to this exact root element. Identity (===), not +// containment — nested roots must not accept each other's items. +export function isItemFromRoot( + rootElement:HTMLElement|null, + data:Record, +):data is SortableItemData { + return rootElement != null + && isSortableItemData(data) + && data.rootElement === rootElement; } export function isSourceListTarget({ diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index 6e1a99c5047c..62b4bb1d5ab4 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -90,10 +90,10 @@ describe('Sortable lists item controller', () => { function fakeRoot( element = document.createElement('div'), - { moving = false, acceptedType = null as string|null } = {}, + { moving = false } = {}, ):SortableListsRoot { Object.defineProperty(element, 'isConnected', { value: true, configurable: true }); - return { element, moving, acceptedType }; + return { element, moving }; } function connectedControllerFor( @@ -323,11 +323,11 @@ describe('Sortable lists item controller', () => { })).toBe(false); }); - it('does not accept drops whose type is rejected by the root', () => { + it('does not accept a drop whose type differs from its own', () => { const root = document.createElement('div'); const targetElement = document.createElement('article'); - connectedControllerFor(targetElement, { root: fakeRoot(root, { acceptedType: 'work_package' }) }); + connectedControllerFor(targetElement, { root: fakeRoot(root) }); expect(vi.mocked(dropTargetForElements).mock.lastCall?.[0].canDrop?.({ element: targetElement, @@ -339,6 +339,22 @@ describe('Sortable lists item controller', () => { })).toBe(false); }); + it('accepts a same-type drop from another item in its root', () => { + const root = document.createElement('div'); + const targetElement = document.createElement('article'); + + connectedControllerFor(targetElement, { root: fakeRoot(root) }); + + expect(vi.mocked(dropTargetForElements).mock.lastCall?.[0].canDrop?.({ + element: targetElement, + input: {} as never, + source: { + data: sortableItemData({ type: 'item', itemId: '456', rootElement: root }), + element: document.createElement('article'), + } as never, + })).toBe(true); + }); + it('does not accept drops while the root is moving another item', () => { const root = document.createElement('div'); const targetElement = document.createElement('article'); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index 47fd8b18b0f5..27876ce1cacb 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -39,8 +39,7 @@ import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/prevent-unha import { Controller } from '@hotwired/stimulus'; import { closestInteractiveElement } from 'core-stimulus/helpers/interactive-element-helper'; import { - canAccept, - isSortableItemData, + isItemFromRoot, sortableItemData, type RootAwareChild, type SortableItemData, @@ -101,7 +100,7 @@ export default class ItemController extends Controller implements R } // Both values are required: an item with an empty id can never be persisted, - // and an empty type never matches the root's accepted type, so the item would + // and an empty type never matches a list's accepted type, so the item would // appear draggable yet silently refuse every drop. Surface that wiring mistake. private warnOnMissingValues():void { if (!this.hasIdValue) { @@ -170,11 +169,15 @@ export default class ItemController extends Controller implements R return false; } - if (isSortableItemData(source.data) && source.data.itemId === this.idValue) { - return false; - } - - return canAccept(root, source.data); + // Same-type proxy: an item sits in a list, so its own type is one the + // list accepts, and matching the dragged type against this item's type + // needs no list lookup. This assumes every item's type is among its + // list's accepted types; if a mixed-type list ever held an item of a + // non-accepted type, this target would accept a drop the list rejects, + // resolving to a silent no-op. Holds today (one type per list). + return isItemFromRoot(root.element, source.data) + && source.data.itemId !== this.idValue + && source.data.type === this.typeValue; }, getData: ({ input }) => { return attachClosestEdge(this.getItemData(), { diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts index 147c7a8d1cc9..4dc3c8c4d709 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts @@ -69,26 +69,29 @@ describe('Sortable lists list controller', () => { function fakeRoot( element = document.createElement('div'), - { moving = false, acceptedType = null as string|null } = {}, + { moving = false } = {}, ):SortableListsRoot { - return { element, moving, acceptedType }; + return { element, moving }; } async function connectedListFor({ type = 'sprint', id = '7', dropPosition = null, + acceptedType = 'work_package', root = fakeRoot(), }:{ type?:string|null; id?:string|null; dropPosition?:string|null; + acceptedType?:string|null; root?:SortableListsRoot|null; } = {}) { fixture.innerHTML = `
                                                                                                    `; const list = fixture.querySelector('[data-controller~="sortable-lists--list"]')!; @@ -123,12 +126,23 @@ describe('Sortable lists list controller', () => { expect(dropTargetForElements).toHaveBeenCalledWith(expect.objectContaining({ element: list })); }); - it('does not register without a list type value', async () => { - const { list } = await connectedListFor({ type: null, root: null }); + it('does not register without an accepted type', async () => { + const { list } = await connectedListFor({ acceptedType: null, root: null }); expect(dropTargetForElements).not.toHaveBeenCalledWith(expect.objectContaining({ element: list })); }); + it('warns and does not register when it accepts a type but has no list type', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const { list } = await connectedListFor({ type: null, acceptedType: 'work_package', root: null }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('type'), expect.anything()); + expect(dropTargetForElements).not.toHaveBeenCalledWith(expect.objectContaining({ element: list })); + + warn.mockRestore(); + }); + it('exposes its list payload through getData', async () => { const { list } = await connectedListFor({ type: 'sprint', id: '7' }); @@ -138,7 +152,7 @@ describe('Sortable lists list controller', () => { it('resolves the child
                                                                                                      as the rows container on the payload', async () => { fixture.innerHTML = ` -
                                                                                                      +
                                                                                                        `; @@ -180,7 +194,7 @@ describe('Sortable lists list controller', () => { it('accepts a same-root item of the accepted type', async () => { const root = document.createElement('div'); - const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package' }) }); + const { list } = await connectedListFor({ acceptedType: 'work_package', root: fakeRoot(root) }); expect(dropTargetOptionsFor(list)?.canDrop?.({ element: list, input: {} as never, source: source(root, 'work_package') })) .toBe(true); @@ -188,7 +202,7 @@ describe('Sortable lists list controller', () => { it('rejects an item whose type is not accepted', async () => { const root = document.createElement('div'); - const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package' }) }); + const { list } = await connectedListFor({ acceptedType: 'work_package', root: fakeRoot(root) }); expect(dropTargetOptionsFor(list)?.canDrop?.({ element: list, input: {} as never, source: source(root, 'meeting_agenda_item') })) .toBe(false); @@ -288,7 +302,7 @@ describe('Sortable lists list controller', () => { it('rejects drops while the root is moving', async () => { const root = document.createElement('div'); - const { list } = await connectedListFor({ root: fakeRoot(root, { acceptedType: 'work_package', moving: true }) }); + const { list } = await connectedListFor({ root: fakeRoot(root, { moving: true }) }); expect(dropTargetOptionsFor(list)?.canDrop?.({ element: list, input: {} as never, source: source(root, 'work_package') })) .toBe(false); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts index 4596d982f509..12c361964ec2 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.ts @@ -30,7 +30,7 @@ import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element import { type DragLocationHistory } from '@atlaskit/pragmatic-drag-and-drop/types'; import { Controller } from '@hotwired/stimulus'; import { - canAccept, + isItemFromRoot, isSortableItemData, sortableListData, type RootAwareChild, @@ -48,6 +48,7 @@ export default class ListController extends Controller implements R type: String, id: String, dropPosition: { type: String, default: 'end' }, + acceptedType: String, }; static elements = { rowsContainer: ':scope > ul' }; @@ -57,6 +58,8 @@ export default class ListController extends Controller implements R declare readonly idValue:string; declare readonly hasIdValue:boolean; declare readonly dropPositionValue:string; + declare readonly acceptedTypeValue:string; + declare readonly hasAcceptedTypeValue:boolean; // Provided by the stimulus-elements blessing, declared manually in the same // style as the controller's values/targets. @@ -67,8 +70,19 @@ export default class ListController extends Controller implements R private cleanupFn?:CleanupFn; connect():void { - // A list without a type value is not a drop target. + // A list that accepts no item type is not a drop target (display-only). + if (!this.hasAcceptedTypeValue) { + return; + } + + // The list's own type doubles as the persisted list_type: a droppable list + // without it would accept drops it cannot persist. Surface that mistake. if (!this.hasTypeValue) { + console.warn( + 'sortable-lists--list has an accepted type but is missing its required type value ' + + '(data-sortable-lists--list-type-value); it cannot accept drops.', + this.element, + ); return; } @@ -145,7 +159,7 @@ export default class ListController extends Controller implements R return false; } - return canAccept(root, data); + return isItemFromRoot(root.element, data) && data.type === this.acceptedTypeValue; } // The list is the item targets' parent drop target, so its onDrag keeps firing diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts index 54e72b79000b..57764a5ec303 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts @@ -67,7 +67,7 @@ describe('Sortable lists scrollable controller', () => { } function stubRoot(element:HTMLElement):SortableListsRoot { - return { element, moving: false, acceptedType: 'work_package' }; + return { element, moving: false }; } function scrollArgs(element:HTMLElement, data:Record) { diff --git a/modules/backlogs/app/components/backlogs/bucket_component.html.erb b/modules/backlogs/app/components/backlogs/bucket_component.html.erb index 7bdac49ec566..b6d3cd655c54 100644 --- a/modules/backlogs/app/components/backlogs/bucket_component.html.erb +++ b/modules/backlogs/app/components/backlogs/bucket_component.html.erb @@ -37,6 +37,7 @@ See COPYRIGHT and LICENSE files for more details. drag_and_drop: { list_type:, list_id: backlog_bucket.id, + accepted_type: "work_package", drop_position: "start" }, params: backlog_filter_params, diff --git a/modules/backlogs/app/components/backlogs/inbox_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_component.html.erb index 4697a024be53..c0c53648549a 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb @@ -41,6 +41,7 @@ See COPYRIGHT and LICENSE files for more details. data: { controller: "sortable-lists--list", sortable_lists__list_type_value: list_type, + sortable_lists__list_accepted_type_value: "work_package", sortable_lists__list_drop_position_value: "start" } ) diff --git a/modules/backlogs/app/components/backlogs/sprint_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_component.html.erb index e10941f8e5a5..2196ae7f2d50 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_component.html.erb @@ -37,6 +37,7 @@ See COPYRIGHT and LICENSE files for more details. drag_and_drop: { list_type:, list_id: sprint.id, + accepted_type: "work_package", drop_position: "start" }, params: backlog_filter_params, diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb index 8bb1fab3203f..9da6495f8741 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb @@ -117,7 +117,8 @@ def merge_drag_and_drop_data! def drag_and_drop_data data = { controller: "sortable-lists--list", - sortable_lists__list_type_value: drag_and_drop.fetch(:list_type) + sortable_lists__list_type_value: drag_and_drop.fetch(:list_type), + sortable_lists__list_accepted_type_value: drag_and_drop.fetch(:accepted_type) } if drag_and_drop[:list_id].present? data[:sortable_lists__list_id_value] = drag_and_drop[:list_id] diff --git a/modules/backlogs/app/views/backlogs/backlog/show.html.erb b/modules/backlogs/app/views/backlogs/backlog/show.html.erb index 43e2b8d55f23..68cf8df59e32 100644 --- a/modules/backlogs/app/views/backlogs/backlog/show.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/show.html.erb @@ -50,7 +50,6 @@ See COPYRIGHT and LICENSE files for more details. data: { controller: "backlogs--list-refresh backlogs--split-view-sync sortable-lists", action: "#{Backlogs::WorkPackagesController::WORK_PACKAGE_MOVED_EVENT}@document->backlogs--split-view-sync#onWorkPackageMoved", - sortable_lists_accepted_type_value: "work_package", sortable_lists_move_url_template_value: backlogs_move_url_template(@project), sortable_lists_sortable_lists__list_outlet: "#backlogs_container [data-controller~='sortable-lists--list']", sortable_lists_sortable_lists__item_outlet: "#backlogs_container [data-controller~='sortable-lists--item']", diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index fb5ddb54f008..265c58535bda 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -125,6 +125,7 @@ def render_component expect(box["data-controller"]).to include("sortable-lists--list") expect(box["data-sortable-lists--list-type-value"]).to eq(list_type) expect(box["data-sortable-lists--list-id-value"]).to eq(backlog_bucket.id.to_s) + expect(box["data-sortable-lists--list-accepted-type-value"]).to eq("work_package") expect(box["data-sortable-lists--list-drop-position-value"]).to eq("start") end end diff --git a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb index 02da358af14e..c76389495659 100644 --- a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb @@ -71,6 +71,7 @@ def render_component expect(box["data-controller"]).to include("sortable-lists--list") expect(box["data-sortable-lists--list-type-value"]).to eq(list_type) expect(box["data-sortable-lists--list-id-value"]).to be_nil + expect(box["data-sortable-lists--list-accepted-type-value"]).to eq("work_package") expect(box["data-sortable-lists--list-drop-position-value"]).to eq("start") end end diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index 87f82c9540d7..2e2e94f8c172 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -108,6 +108,7 @@ def menu_items expect(box["data-controller"]).to include("sortable-lists--list") expect(box["data-sortable-lists--list-type-value"]).to eq(list_type) expect(box["data-sortable-lists--list-id-value"]).to eq(sprint.id.to_s) + expect(box["data-sortable-lists--list-accepted-type-value"]).to eq("work_package") expect(box["data-sortable-lists--list-drop-position-value"]).to eq("start") end end diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb index def10b3239f7..416b52918028 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb @@ -284,7 +284,7 @@ def render_component(work_packages:, container:, drag_and_drop:) context "with drag_and_drop configured" do let(:drag_and_drop) do - { list_type: "sprint", list_id: sprint.id } + { list_type: "sprint", list_id: sprint.id, accepted_type: "work_package" } end it "wires the list controller and value attributes onto the box" do @@ -292,6 +292,7 @@ def render_component(work_packages:, container:, drag_and_drop:) expect(box["data-controller"]).to include("sortable-lists--list") expect(box["data-sortable-lists--list-type-value"]).to eq("sprint") expect(box["data-sortable-lists--list-id-value"]).to eq(sprint.id.to_s) + expect(box["data-sortable-lists--list-accepted-type-value"]).to eq("work_package") end end end diff --git a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb index e68c0fde2ddf..74b3a4655c56 100644 --- a/modules/backlogs/spec/requests/backlogs/backlog_spec.rb +++ b/modules/backlogs/spec/requests/backlogs/backlog_spec.rb @@ -75,7 +75,6 @@ expect(response).to have_http_status(:ok) expect(response.body).to include('data-controller="backlogs--list-refresh backlogs--split-view-sync sortable-lists"') - expect(response.body).to include('data-sortable-lists-accepted-type-value="work_package"') expect(response.body).to include( %(data-sortable-lists-move-url-template-value="/projects/#{project.identifier}/backlogs/work_packages/{id}/move") ) From f5bfafa0e1dfcddf2cf26f3bcd3d2a6c7901d153 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 7 Jul 2026 17:53:47 +0100 Subject: [PATCH 103/105] Consolidate WorkPackage#to_s and #infoline Folds the near-duplicate `to_s` and `infoline` formatters into a single `to_fs(style)` carrying the two existing layouts verbatim (`:heading`, `:caption`); `to_s` delegates to it. Removes infoline and its never-passed `show_standard_type` argument, migrating its one caller. --- app/models/work_package.rb | 19 ++-- app/views/work_packages/show.html.erb | 2 +- .../features/work_packages/navigation_spec.rb | 2 +- spec/models/work_package_spec.rb | 96 ++++++++++++------- 4 files changed, 77 insertions(+), 42 deletions(-) diff --git a/app/models/work_package.rb b/app/models/work_package.rb index 32199fa1fac4..4e30d19c08bb 100644 --- a/app/models/work_package.rb +++ b/app/models/work_package.rb @@ -293,13 +293,20 @@ def assignable_versions(only_open: true) end end - def to_s - "#{type.name unless type.is_standard} #{formatted_id}: #{subject}" - end + def to_s = to_fs - def infoline(show_standard_type: true) - type_name = show_standard_type || !type.is_standard ? type.name : "" - "#{type_name}: #{subject} (#{formatted_id})" + # Human-readable label composed from the work package's type, id and subject. + # + # @param style [Symbol] + # :heading => "Bug #42: Fix login" (non-standard type; type name omitted for standard types) + # :caption => "Bug: Fix login (#42)" (type name always shown, even for standard types) + # @return [String] + def to_fs(style = :heading) + case style + when :heading then "#{type.name unless type.is_standard} #{formatted_id}: #{subject}" + when :caption then "#{type.name}: #{subject} (#{formatted_id})" + else raise ArgumentError, "unknown format style: #{style.inspect}" + end end # Return true if the work_package is closed, otherwise false diff --git a/app/views/work_packages/show.html.erb b/app/views/work_packages/show.html.erb index b30dcacaeeb6..1bb461a68340 100644 --- a/app/views/work_packages/show.html.erb +++ b/app/views/work_packages/show.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title h(work_package.infoline) %> +<% html_title h(work_package.to_fs(:caption)) %> <% content_for :sidebar do %> <%= render partial: 'sidebar' %> diff --git a/spec/features/work_packages/navigation_spec.rb b/spec/features/work_packages/navigation_spec.rb index 43aa4a9c1acf..dda862971f98 100644 --- a/spec/features/work_packages/navigation_spec.rb +++ b/spec/features/work_packages/navigation_spec.rb @@ -36,7 +36,7 @@ let(:work_package) { build(:work_package, project:) } let(:global_html_title) { Components::HtmlTitle.new } let(:project_html_title) { Components::HtmlTitle.new project } - let(:wp_title_segment) { work_package.infoline } + let(:wp_title_segment) { work_package.to_fs(:caption) } let!(:query) do query = build(:query, user:, project:) diff --git a/spec/models/work_package_spec.rb b/spec/models/work_package_spec.rb index 4c92f053b472..30b992b62833 100644 --- a/spec/models/work_package_spec.rb +++ b/spec/models/work_package_spec.rb @@ -920,55 +920,83 @@ def stub_shared_versions(version = nil) end end - describe "#infoline" do - let(:infoline_type) { create(:type, name: "Task") } - let(:infoline_work_package) do - create(:work_package, subject: "Hello world", project: infoline_project, type: infoline_type) - end + describe "#to_fs" do + describe ":heading style (the default, used by #to_s)" do + let(:task_type) { create(:type_task) } - context "when semantic mode is active", - with_settings: { work_packages_identifier: "semantic" } do - let(:infoline_project) { create(:project, identifier: "MYPROJ") } + context "in classic mode", + with_settings: { work_packages_identifier: "classic" } do + let(:work_package) { create(:work_package, project:, type: task_type, subject: "Hello world") } - before { infoline_work_package } + it "renders the type, the `#`-prefixed numeric id and the subject" do + expect(work_package.to_fs(:heading)).to eq("Task ##{work_package.id}: Hello world") + end - it "renders the semantic identifier without a hash prefix" do - expect(infoline_work_package.reload.infoline).to eq("Task: Hello world (MYPROJ-1)") + it "is the default style, and #to_s delegates to it" do + expect(work_package.to_fs).to eq("Task ##{work_package.id}: Hello world") + expect(work_package.to_s).to eq(work_package.to_fs(:heading)) + end + + it "omits the type name for standard types (leading space preserved)" do + standard_wp = create(:work_package, project:, type:, subject: "Hello world") + expect(standard_wp.to_fs(:heading)).to eq(" ##{standard_wp.id}: Hello world") + end end - end - context "when semantic mode is not active", - with_settings: { work_packages_identifier: "classic" } do - let(:infoline_project) { create(:project) } + context "in semantic mode", + with_settings: { work_packages_identifier: "semantic" } do + let(:project) { create(:project, identifier: "MACROPROJ", types: [task_type]) } + let(:work_package) { create(:work_package, project:, type: task_type, subject: "Hello world") } - it "renders the hash-prefixed numeric id" do - expect(infoline_work_package.infoline).to eq("Task: Hello world (##{infoline_work_package.id})") + it "renders the semantic identifier without a `#` prefix" do + wp = work_package.reload + expect(wp.to_fs(:heading)).to eq("Task #{wp.display_id}: Hello world") + expect(wp.to_fs(:heading)).not_to include("##{wp.id}") + end end end - end - describe "#to_s" do - let(:task_type) { create(:type_task) } + describe ":caption style" do + let(:caption_type) { create(:type, name: "Task") } + let(:caption_work_package) do + create(:work_package, subject: "Hello world", project: caption_project, type: caption_type) + end + + context "in semantic mode", + with_settings: { work_packages_identifier: "semantic" } do + let(:caption_project) { create(:project, identifier: "MYPROJ") } - context "in classic mode", - with_settings: { work_packages_identifier: "classic" } do - let(:work_package) { create(:work_package, project:, type: task_type, subject: "Hello world") } + before { caption_work_package } - it "renders the numeric id with a `#` prefix" do - expect(work_package.to_s).to eq("Task ##{work_package.id}: Hello world") + it "renders the type, subject and the semantic id in parentheses" do + expect(caption_work_package.reload.to_fs(:caption)).to eq("Task: Hello world (MYPROJ-1)") + end end - end - context "in semantic mode", - with_settings: { work_packages_identifier: "semantic" } do - let(:project) { create(:project, identifier: "MACROPROJ", types: [task_type]) } - let(:work_package) { create(:work_package, project:, type: task_type, subject: "Hello world") } + context "in classic mode", + with_settings: { work_packages_identifier: "classic" } do + let(:caption_project) { create(:project) } - it "renders the semantic identifier without a `#` prefix" do - wp = work_package.reload - expect(wp.to_s).to eq("Task #{wp.display_id}: Hello world") - expect(wp.to_s).not_to include("##{wp.id}") + it "renders the type, subject and the `#`-prefixed numeric id in parentheses" do + expect(caption_work_package.to_fs(:caption)) + .to eq("Task: Hello world (##{caption_work_package.id})") + end end + + context "with a standard type", + with_settings: { work_packages_identifier: "classic" } do + let(:standard_work_package) { create(:work_package, project:, type:, subject: "Hello world") } + + it "still shows the type name in the caption" do + expect(standard_work_package.to_fs(:caption)) + .to eq("#{type.name}: Hello world (##{standard_work_package.id})") + end + end + end + + it "raises ArgumentError for an unknown style" do + expect { work_package.to_fs(:bogus) } + .to raise_error(ArgumentError, /unknown format style/) end end end From 738a8a065fc3870d598171c2a0a29578d07b89ba Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 5 Jul 2026 12:15:58 +0100 Subject: [PATCH 104/105] Let Space scroll the page on backlogs cards The keydown handler prevented Space's default before ignoring the key, so cards swallowed native page scrolling without offering activation in return. Enter remains the only activation key; Space stays native. This effectively reverts commit 50500d86dfe2e5081f9332e92754467ec6e91c40. --- .../dynamic/backlogs/work-package.controller.spec.ts | 4 ++-- .../controllers/dynamic/backlogs/work-package.controller.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.spec.ts index 00fbbf1f22a5..f40b7c8a9ded 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.spec.ts @@ -93,13 +93,13 @@ describe('Backlogs work package controller', () => { return event; } - it('prevents Space from scrolling the page without activating the card', async () => { + it('lets Space scroll the page natively without hijacking it', async () => { const workPackage = renderWorkPackage(); await nextFrame(); const event = keydown(workPackage, ' '); - expect(event.defaultPrevented).toBe(true); + expect(event.defaultPrevented).toBe(false); expect(navigation.openSplitPane).not.toHaveBeenCalled(); expect(navigation.openFullPane).not.toHaveBeenCalled(); }); diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.ts index 63238e30dd18..77630063b4d9 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/work-package.controller.ts @@ -160,7 +160,7 @@ export default class WorkPackageController extends Controller imple } private onKeydown(event:KeyboardEvent):void { - if (event.key !== 'Enter' && event.key !== ' ') return; + if (event.key !== 'Enter') return; const target = event.target; if (!(target instanceof HTMLElement)) return; @@ -168,7 +168,6 @@ export default class WorkPackageController extends Controller imple if (this.shouldIgnoreKeyboardTarget(target)) return; event.preventDefault(); - if (event.key === ' ') return; if (event.shiftKey) { this.openFullPane(); From 516e9021a75430f9161074e6c8c407215fe8dfa4 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 7 Jul 2026 18:37:19 +0100 Subject: [PATCH 105/105] Scope card focus and Enter hint to backlogs cards Moves the card tabindex from the base component into the Backlogs subclass. The base card has no Enter handler, so a base-level tabindex left a dead tab stop before the subject link; only the subclass wires the `backlogs--work-package` activation handler, so it owns card focusability and the `:has(> .Box-card:focus-visible)` row styling. Builds the card's accessible name from `to_fs(:caption)` rather than a hand-rolled `display_id` string, so the id renders through `formatted_id` and the type is announced, alongside `aria-keyshortcuts` advertising the Enter activation. Addresses review comments r3535719177 (tabindex) and r3535731407 (aria) on PR #24106. --- .../work_package_item.rb | 7 ++++- .../work_package_card_list_item_component.rb | 26 +++++++++++++++---- ...k_package_card_list_item_component_spec.rb | 12 +++++++++ .../common/border_box_list_component_spec.rb | 9 +++++++ 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/app/components/open_project/common/border_box_list_component/work_package_item.rb b/app/components/open_project/common/border_box_list_component/work_package_item.rb index e4d5ccd69a5d..13561cdd79cf 100644 --- a/app/components/open_project/common/border_box_list_component/work_package_item.rb +++ b/app/components/open_project/common/border_box_list_component/work_package_item.rb @@ -105,7 +105,12 @@ def card # # @return [ApplicationComponent] def build_card - WorkPackageCardComponent.new(work_package:, classes: card_classes) + WorkPackageCardComponent.new(work_package:, **card_arguments) + end + + # @return [Hash] keyword arguments forwarded to the card component. + def card_arguments + { classes: card_classes } end def card_classes diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb index d38e725e05e2..79a06989e481 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb @@ -58,12 +58,17 @@ def menu_src url_helpers.menu_project_backlogs_work_package_path(project, work_package) end + # @return [Hash] card arguments carrying the Backlogs-only keyboard wiring. + # `tabindex` lives here rather than in the base because only this subclass + # attaches the `backlogs--work-package` Enter handler; a focusable base card + # would be a dead tab stop. The `:has(> .Box-card:focus-visible)` row rule + # depends on this focusability. def card_arguments - { - classes: card_classes, - tabindex: 0, - data: card_data - } + arguments = super + arguments[:tabindex] = 0 + arguments[:data] = merge_data(arguments, { data: card_data }) + arguments[:aria] = merge_aria(arguments, { aria: card_aria }) + arguments end def card_data @@ -81,6 +86,17 @@ def card_data data.merge(sortable_lists__item_target: "preview handle") end + # @return [Hash] ARIA wiring announcing the card's Enter activation without + # claiming button or draggable semantics. Lives in the subclass because + # only here is the `backlogs--work-package` Enter handler attached; the + # base card is focusable for styling alone and must not claim a shortcut. + def card_aria + { + keyshortcuts: "Enter", + label: work_package.to_fs(:caption) + } + end + def draggable_data { controller: "sortable-lists--item", diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb index 6b957bab192e..7d0c05f37993 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb @@ -151,6 +151,18 @@ ) end + it "announces Enter activation to assistive tech without a button or drag role" do + expect(rendered_card).to have_css( + ".op-work-package-card", + role: "article", + aria: { + keyshortcuts: "Enter", + label: work_package.to_fs(:caption), + roledescription: nil + } + ) + end + it "does not wire the card as the draggable item" do expect(rendered_card).to have_css( ".op-work-package-card[data-controller~='backlogs--work-package']" diff --git a/spec/components/open_project/common/border_box_list_component_spec.rb b/spec/components/open_project/common/border_box_list_component_spec.rb index 14326b4b6b3f..d3a6b6b835ea 100644 --- a/spec/components/open_project/common/border_box_list_component_spec.rb +++ b/spec/components/open_project/common/border_box_list_component_spec.rb @@ -527,6 +527,15 @@ def call expect(rendered_component).to have_no_css(".Box-card--draggable") end + it "does not claim Enter activation on the base card (no Enter handler there)" do + expect(rendered_component).to have_css(".Box-card", aria: { keyshortcuts: nil }) + end + + it "does not make the base card keyboard-focusable (no Enter handler there)" do + expect(rendered_component).to have_css(".op-work-package-card") + expect(rendered_component).to have_no_css(".op-work-package-card[tabindex]") + end + it "sets the test selector" do item = described_class::WorkPackageItem.new( work_package:,