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 0b879b92a1e9..679bbe7a8e33 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 @@ -30,7 +30,7 @@ See COPYRIGHT and LICENSE files for more details. <%= grid_layout( "op-work-package-card", tag: :article, - classes: { "op-work-package-card_with-metric": metric? } + **card_arguments ) do |grid| %> <% grid.with_area(:info_line) do %> <%# TODO(73089): allow callers to pass arguments through to InfoLineComponent (e.g. status presentation, variants). %> @@ -43,7 +43,7 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% end %> - <% grid.with_area(:menu) do %> + <% grid.with_area(:menu, classes: 'hidden-for-drag-preview') do %> <% if menu? %> <%= menu %> <% else %> 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 224f342a9089..80523f33137d 100644 --- a/app/components/open_project/common/work_package_card_component.rb +++ b/app/components/open_project/common/work_package_card_component.rb @@ -49,11 +49,26 @@ class WorkPackageCardComponent < ApplicationComponent # @param work_package [WorkPackage] the work package this card represents. # @param menu_src [String, NilClass] optional lazy menu source. Prefer the # `with_menu(src:)` slot for new call sites. - def initialize(work_package:, menu_src: nil) + # @param system_arguments [Hash] forwarded to the root card element. + def initialize(work_package:, menu_src: nil, **system_arguments) super() @work_package = work_package @menu_src = menu_src + @system_arguments = system_arguments + @system_arguments[:classes] = class_names( + @system_arguments[:classes], + "Box-card" + ) + end + + def card_arguments + @system_arguments.deep_dup.tap do |arguments| + arguments[:classes] = class_names( + arguments[:classes], + "op-work-package-card_with-metric": metric? + ) + end end end end diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index ec5086e25d0e..8efc05b4d823 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -41,6 +41,10 @@ en: Drag on editor field to inline image or reference attachment. Closed editor fields will be opened while you keep dragging. quarantined_hint: "The file is quarantined, as a virus was found. It is not available for download." + sortable_lists: + drag_handle: + instructions: "Drag to reposition this item." + autocomplete_ng_select: add_tag: "Add item" clear_all: "Clear all" 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/frontend/package-lock.json b/frontend/package-lock.json index 1cf4c73c8a5f..bc5efaea946d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,8 @@ "@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-hitbox": "^1.1.0", "@blocknote/core": "^0.44.2", "@blocknote/mantine": "^0.44.2", "@blocknote/react": "^0.44.2", @@ -2450,6 +2452,27 @@ "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-hitbox": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.1.0.tgz", + "integrity": "sha512-JWt6eVp6Br2FPHRM8s0dUIHQk/jFInGP1f3ti5CdtM1Ji5/pt8Akm44wDC063Gv2i5RGseixtbW0z/t6RYtbdg==", + "license": "Apache-2.0", + "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.6.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", @@ -12804,6 +12827,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", @@ -21894,6 +21923,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", @@ -27917,6 +27952,25 @@ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "dev": true }, + "@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==", + "requires": { + "@babel/runtime": "^7.0.0", + "bind-event-listener": "^3.0.0", + "raf-schd": "^4.0.3" + } + }, + "@atlaskit/pragmatic-drag-and-drop-hitbox": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.1.0.tgz", + "integrity": "sha512-JWt6eVp6Br2FPHRM8s0dUIHQk/jFInGP1f3ti5CdtM1Ji5/pt8Akm44wDC063Gv2i5RGseixtbW0z/t6RYtbdg==", + "requires": { + "@atlaskit/pragmatic-drag-and-drop": "^1.6.0", + "@babel/runtime": "^7.0.0" + } + }, "@authress/login": { "version": "2.6.417", "resolved": "https://registry.npmjs.org/@authress/login/-/login-2.6.417.tgz", @@ -34498,6 +34552,11 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, + "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==" + }, "body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -40767,6 +40826,11 @@ "side-channel": "^1.1.0" } }, + "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==" + }, "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 fb87931e3c78..e9fb3519b5f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -75,6 +75,8 @@ "@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-hitbox": "^1.1.0", "@blocknote/core": "^0.44.2", "@blocknote/mantine": "^0.44.2", "@blocknote/react": "^0.44.2", diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index f9f3869f6c03..13611bd4ddc2 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -156,42 +156,76 @@ 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) -.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--focus-blue +// &:focus-visible +// background-color: var(--bgColor-accent-muted) .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-normal) + 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) + .hidden-for-drag-preview + visibility: hidden // Apply the mobile styles as soon as the banner itself is small // Styles are copied from the PVC repo diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts deleted file mode 100644 index db868d591f5a..000000000000 --- a/frontend/src/stimulus/controllers/dynamic/backlogs.controller.ts +++ /dev/null @@ -1,61 +0,0 @@ -//-- 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 { 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; - private subscription:Subscription|null = null; - - // eslint-disable-next-line @typescript-eslint/no-misused-promises - async connect() { - const { services: { halEvents } } = await window.OpenProject.getPluginContext(); - - this.service = halEvents; - this.subscription = this.service.aggregated$('WorkPackage') - .pipe(filter((events) => events.some((event) => event.eventType === 'updated'))) - .subscribe(() => { this.refreshList(); }); - } - - disconnect() { - this.subscription?.unsubscribe(); - this.subscription = null; - this.service = null; - } - - private refreshList() { - void this.listElement.reload(); - } - - private get listElement() { - return this.element.querySelector('#backlogs_container')!; - } -} diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts new file mode 100644 index 000000000000..bf88d5e70920 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.spec.ts @@ -0,0 +1,84 @@ +//-- 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 { Application } from '@hotwired/stimulus'; + +import type StoryControllerType from './story.controller'; + +describe('Backlogs story controller', () => { + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + + let application:Application; + let fixture:HTMLElement; + let StoryController:typeof StoryControllerType; + + beforeAll(async () => { + ({ default: StoryController } = await import('./story.controller')); + }); + + beforeEach(() => { + fixture = document.createElement('div'); + document.body.appendChild(fixture); + + application = Application.start(); + application.register('backlogs--story', StoryController); + }); + + afterEach(() => { + application.stop(); + fixture.remove(); + }); + + function renderStory() { + fixture.innerHTML = ` +
+ Story +
+ `; + + return fixture.querySelector('[data-controller="backlogs--story"]')!; + } + + it('prevents Space from scrolling the page without activating the card', async () => { + const story = renderStory(); + const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); + + await nextFrame(); + story.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); +}); diff --git a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts index 64595e9dc010..3fe8cbfcbe42 100644 --- a/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/backlogs/story.controller.ts @@ -141,7 +141,7 @@ export default class StoryController extends Controller implements } private onKeydown(event:KeyboardEvent):void { - if (event.key !== 'Enter') return; + if (event.key !== 'Enter' && event.key !== ' ') return; const target = event.target; if (!(target instanceof HTMLElement)) return; @@ -149,6 +149,8 @@ export default class StoryController extends Controller implements if (this.shouldIgnoreKeyboardTarget(target)) return; event.preventDefault(); + if (event.key === ' ') return; + if (event.shiftKey) { this.openFullPane(); } else { 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..b1fa43329eac --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -0,0 +1,486 @@ +//-- 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 { Application } 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 type SortableListsControllerType from './sortable-lists.controller'; +import type { sortableItemData as sortableItemDataFn } from './sortable-lists/drag-and-drop'; + +describe('Sortable lists controller', () => { + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + 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 application:Application; + 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: { + data: itemData( + sourceElement.getAttribute('data-sortable-lists--item-id-value')!, + 'work_package', + sourceElement.getAttribute('data-sortable-lists--item-move-url-value') ?? undefined, + ), + element: sourceElement, + }, + location: { + current: { + dropTargets: [{ + data: { + type: list.getAttribute('data-sortable-lists-list-type'), + listId: list.getAttribute('data-sortable-lists-list-id'), + }, + element: list, + }], + input: input(), + }, + }, + } as never); + } + + function itemData(itemId = '1', type = 'work_package', moveUrl?:string) { + return sortableItemData({ itemId, moveUrl, type }); + } + + function dropTargetOptionsFor(element:HTMLElement) { + return vi.mocked(dropTargetForElements).mock.calls.find(([options]) => options.element === element)?.[0]; + } + + beforeEach(() => { + vi.clearAllMocks(); + + fixture = document.createElement('div'); + loadingIndicator = document.createElement('div'); + loadingIndicator.id = 'global-loading-indicator'; + loadingIndicator.hidden = true; + document.body.appendChild(fixture); + document.body.appendChild(loadingIndicator); + + application = Application.start(); + application.register('sortable-lists', SortableListsController); + }); + + afterEach(() => { + application.stop(); + fixture.remove(); + loadingIndicator.remove(); + }); + + it('does not turn a list-only drop onto the source list into an append move', async () => { + const { sourceList, firstSourceItem } = renderFixture(); + + await nextFrame(); + await dropCurrentItemOnList(firstSourceItem, sourceList); + + expect(FetchRequest).not.toHaveBeenCalled(); + }); + + it('appends the item when list-only dropping onto another list', async () => { + const { targetList, firstSourceItem } = renderFixture(); + + await 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_item_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 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 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 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; + })), + }; + } as never); + + const { root, targetList, firstSourceItem } = renderFixture(); + + await 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; + })), + }; + } as never); + + const { targetList, firstSourceItem } = renderFixture(); + + await nextFrame(); + await dropCurrentItemOnList(firstSourceItem, targetList); + + expect(vi.mocked(monitorForElements).mock.lastCall?.[0].canMonitor?.({ + source: { + data: itemData(), + dragHandle: null, + element: firstSourceItem, + }, + initial: {} as never, + })).toBe(false); + expect(dropTargetOptionsFor(targetList)?.canDrop?.({ + element: targetList, + input: input(), + source: { + data: itemData(), + element: firstSourceItem, + }, + } as never)).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'))), + }; + } as never); + + const { root, targetList, firstSourceItem } = renderFixture(); + + await 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 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 nextFrame(); + + expect(dropTargetOptionsFor(targetList)?.canDrop?.({ + element: targetList, + input: input(), + source: { + data: itemData('1', 'work_package'), + element: firstSourceItem, + }, + } as never)).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 nextFrame(); + + for (const list of [sourceList, targetList]) { + expect(dropTargetOptionsFor(list)?.canDrop?.({ + element: list, + input: input(), + source: { + data: itemData('1', 'meeting_agenda_item'), + element: firstSourceItem, + }, + } as never)).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 nextFrame(); + + expect(dropTargetOptionsFor(targetList)?.canDrop?.({ + element: targetList, + input: input(), + source: { + data: itemData('1', 'meeting_agenda_item'), + element: firstSourceItem, + }, + } as never)).toBe(false); + }); + + it('registers scrollable targets for vertical sortable list auto-scrolling', async () => { + const scrollable = renderScrollableFixture(); + + await nextFrame(); + + expect(autoScrollForElements).toHaveBeenCalledWith(expect.objectContaining({ + element: scrollable, + })); + + const options = vi.mocked(autoScrollForElements).mock.lastCall?.[0]; + + expect(options?.canScroll?.({ + element: scrollable, + input: input(), + source: { + data: itemData(), + element: itemRow('1'), + }, + } as never)).toBe(true); + expect(options?.canScroll?.({ + element: scrollable, + input: input(), + source: { + data: { type: 'unrelated' }, + element: document.createElement('div'), + }, + } as never)).toBe(false); + expect(options?.getAllowedAxis?.({ + element: scrollable, + input: input(), + source: { + data: itemData(), + element: itemRow('1'), + }, + } as never)).toEqual('vertical'); + expect(options?.getConfiguration?.({ + element: scrollable, + input: input(), + source: { + data: itemData(), + element: itemRow('1'), + }, + } as never)).toEqual({ maxScrollSpeed: 'standard' }); + }); + + it('cleans up scrollable target auto-scrolling on disconnect', async () => { + const scrollableCleanup = vi.fn(); + vi.mocked(autoScrollForElements).mockReturnValue(scrollableCleanup); + + renderScrollableFixture(); + + await nextFrame(); + + fixture.innerHTML = ''; + await nextFrame(); + + expect(scrollableCleanup).toHaveBeenCalledOnce(); + }); +}); 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..9960ad46a42e --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -0,0 +1,267 @@ +//-- 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, + type ElementEventPayloadMap, + monitorForElements, +} 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, + sortableListsMovingAttribute, +} 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(); + + connect():void { + this.monitorCleanupFn = monitorForElements({ + canMonitor: ({ source }) => !this.moving && isSortableItemData(source.data), + onDrop: (args) => { + void this.handleDrop(args); + }, + }); + } + + 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 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); + } + + 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; + } + + const moveUrl = this.resolveMoveUrl(source.data); + if (!moveUrl) { + return; + } + + const targetItem = location.current.dropTargets.find(({ data, element }) => ( + isSortableItemData(data) && element instanceof HTMLElement + )); + 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 ?? location.current.dropTargets[0]?.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, + }); + + 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', + }, + })); + } + + 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; + } +} 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..2e910897ac7d --- /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_item_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_item_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..a33d0601b6d7 --- /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_item_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..97ff3a2b89f9 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -0,0 +1,531 @@ +//-- 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 { Application } from '@hotwired/stimulus'; + +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 type ItemControllerType from './item.controller'; + +describe('Sortable lists item controller', () => { + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + + 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; + } + + 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?.({} as never); + expect(preventUnhandled.start).toHaveBeenCalledOnce(); + + vi.mocked(draggable).mock.lastCall?.[0].onDrop?.({} as never); + 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 application:Application; + let fixture:HTMLElement; + + beforeEach(() => { + fixture = document.createElement('div'); + document.body.appendChild(fixture); + + application = Application.start(); + application.register('sortable-lists--item', ItemController); + }); + + afterEach(() => { + application.stop(); + fixture.remove(); + }); + + 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 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 drag handle without treating it as keyboard-operable yet', async () => { + const { article } = renderBacklogsRow(); + + await nextFrame(); + + expect(vi.mocked(draggable)).toHaveBeenCalledWith(expect.objectContaining({ + dragHandle: article, + })); + expect(article.getAttribute('aria-roledescription')).toEqual('draggable'); + expect(article.getAttribute('aria-disabled')).toEqual('false'); + expect(article.hasAttribute('role')).toBe(false); + expect(article.hasAttribute('tabindex')).toBe(false); + }); + + it('describes the handle with hidden drag instructions', async () => { + const { row, article } = renderBacklogsRow(); + + await nextFrame(); + + const descriptionId = article.getAttribute('aria-describedby'); + const description = descriptionId ? row.querySelector(`#${descriptionId}`) : null; + + expect(description).not.toBeNull(); + expect(description?.classList.contains('sr-only')).toBe(true); + expect(description?.textContent).toContain('Drag to reposition'); + }); + + it('reflects the sortable lists moving state on the handle', async () => { + fixture.innerHTML = ` +
      +
        +
      • +
        +
      • +
      +
      + `; + + const root = fixture.querySelector('[data-controller="sortable-lists"]')!; + const article = fixture.querySelector('article')!; + + await nextFrame(); + + expect(article.getAttribute('aria-disabled')).toEqual('false'); + + root.setAttribute('data-sortable-lists-moving', 'true'); + await nextFrame(); + + expect(article.getAttribute('aria-disabled')).toEqual('true'); + + root.removeAttribute('data-sortable-lists-moving'); + await nextFrame(); + + expect(article.getAttribute('aria-disabled')).toEqual('false'); + }); + + it('includes the optional move URL in the sortable item data', async () => { + renderBacklogsRow(); + + await nextFrame(); + + expect(vi.mocked(draggable).mock.lastCall?.[0].getInitialData?.({} as never)).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 nextFrame(); + + vi.mocked(draggable).mock.lastCall?.[0].onGenerateDragPreview?.({ + nativeSetDragImage, + } as never); + + 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('refreshes the row registrations after Turbo morphing removes dynamic Pragmatic DnD attributes', async () => { + const { row } = renderBacklogsRow(); + + await nextFrame(); + expect(row.dataset.dropTargetForElement).toEqual('true'); + + row.removeAttribute('data-drop-target-for-element'); + row.dispatchEvent(new CustomEvent('turbo:morph-element', { + bubbles: true, + composed: true, + detail: { + currentElement: row, + newElement: row.cloneNode(true), + }, + })); + + 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, + })); + }); + }); +}); 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..3bb3e9041e87 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -0,0 +1,351 @@ +//-- 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 { attributeTokenList } from 'core-app/shared/helpers/dom-helpers'; +import { closestInteractiveElement } from 'core-stimulus/helpers/interactive-element-helper'; +import { + isSortableItemData, + sortableItemSelector, + sortableItemData, + sortableListsMovingAttribute, + sortableListsRootSelector, + type SortableItemData, +} from './drag-and-drop'; + +type CleanupFn = () => void; + +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; + + private cleanupFn?:CleanupFn; + private dropIndicatorElement?:HTMLElement; + private readonly refreshAfterMorphBound = this.refreshAfterMorph.bind(this); + private static nextDescriptionId = 0; + + connect() { + this.cleanupFn = combine( + this.registerHandleAccessibility(), + this.registerDraggable(), + this.registerDropTarget(), + this.registerTurboMorphRefresh(), + ); + } + + disconnect() { + this.cleanupFn?.(); + this.cleanupFn = undefined; + } + + 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.dropIndicatorElement = nextIndicator.element; + nextIndicator.element.dataset.dropPosition = nextIndicator.edge; + nextIndicator.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; + } + + 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 getItemData():SortableItemData { + return sortableItemData({ + itemId: this.idValue, + moveUrl: this.moveUrlValue || undefined, + type: this.typeValue, + }); + } + + 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 }) => { + setCustomNativeDragPreview({ + nativeSetDragImage, + render: ({ container }) => this.renderPreview(container), + }); + }, + }); + } + + 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 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) { + node.removeAttribute('data-controller'); + node.removeAttribute('data-action'); + node.removeAttribute('data-dragging'); + node.removeAttribute('data-drop-position'); + node.removeAttribute('data-drop-position-owner'); + node.removeAttribute('aria-describedby'); + node.removeAttribute('aria-disabled'); + node.removeAttribute('aria-roledescription'); + + for (const attribute of Array.from(node.attributes)) { + if (/^data-.+--.+-target$/.test(attribute.name)) { + node.removeAttribute(attribute.name); + } + } + } + } + + 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 registerHandleAccessibility():CleanupFn { + if (!this.hasHandleTarget) { + return () => undefined; + } + + const handle = this.handleTarget; + const restoreHandleAttributes = this.captureAttributes(handle, [ + 'aria-describedby', + 'aria-disabled', + 'aria-roledescription', + ]); + const description = this.createHandleDescription(); + const root = this.element.closest(sortableListsRootSelector); + const observer = root ? new MutationObserver(() => this.updateHandleDisabled(handle)) : undefined; + + this.element.append(description); + handle.setAttribute('aria-roledescription', 'draggable'); + this.addDescriptionReference(handle, description.id); + this.updateHandleDisabled(handle); + if (observer && root) { + observer.observe(root, { + attributeFilter: [sortableListsMovingAttribute], + attributes: true, + }); + } + + return () => { + observer?.disconnect(); + description.remove(); + restoreHandleAttributes(); + }; + } + + private captureAttributes(element:HTMLElement, attributes:string[]):CleanupFn { + const originalAttributes = new Map(attributes.map((attribute) => [ + attribute, + element.getAttribute(attribute), + ])); + + return () => { + originalAttributes.forEach((value, attribute) => { + if (value === null) { + element.removeAttribute(attribute); + } else { + element.setAttribute(attribute, value); + } + }); + }; + } + + private createHandleDescription():HTMLElement { + const description = document.createElement('span'); + const id = ItemController.nextDescriptionId += 1; + + description.id = `sortable-lists-drag-handle-instructions-${id}`; + description.classList.add('sr-only'); + description.textContent = this.dragHandleInstructions; + + return description; + } + + private addDescriptionReference(element:HTMLElement, descriptionId:string):void { + attributeTokenList(element, 'aria-describedby').add(descriptionId); + } + + private updateHandleDisabled(handle:HTMLElement):void { + handle.setAttribute( + 'aria-disabled', + this.element.closest(sortableListsRootSelector)?.hasAttribute(sortableListsMovingAttribute) ? 'true' : 'false', + ); + } + + private get dragHandleInstructions():string { + const key = 'js.sortable_lists.drag_handle.instructions'; + const translation = I18n.t(key); + + return translation === key || translation.startsWith('[missing ') ? 'Drag to reposition this item.' : translation; + } + + private registerTurboMorphRefresh():CleanupFn { + document.addEventListener('turbo:morph-element', this.refreshAfterMorphBound); + + return () => { + document.removeEventListener('turbo:morph-element', this.refreshAfterMorphBound); + }; + } + + private refreshAfterMorph(event:Event) { + if (event.target !== this.element) { + return; + } + + this.disconnect(); + this.connect(); + } +} 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/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. diff --git a/modules/backlogs/app/components/backlogs/bucket_component.html.erb b/modules/backlogs/app/components/backlogs/bucket_component.html.erb index e9829d3d0e7c..cda7851732a6 100644 --- a/modules/backlogs/app/components/backlogs/bucket_component.html.erb +++ b/modules/backlogs/app/components/backlogs/bucket_component.html.erb @@ -34,8 +34,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 e2f05287ec0d..0f605ace1426 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb @@ -37,10 +37,8 @@ See COPYRIGHT and LICENSE files for more details. padding: :condensed, 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| %> @@ -63,7 +61,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_sprint_dialog_component.html.erb b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb index e97ec73c14f7..aa9acb29a00e 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 @@ -38,17 +38,22 @@ See COPYRIGHT and LICENSE files for more details. 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}") - end - end + safe_join( + [ + hidden_field_tag(:list_type, "sprint"), + render( + Primer::Alpha::Select.new( + name: "list_id", + label: Sprint.human_model_name, + visually_hide_label: true + ) + ) do |select| + 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 c798db47e126..4ee16782b7cf 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_component.html.erb @@ -34,8 +34,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 f0b69e46108b..ad477600e649 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 @@ -54,7 +55,11 @@ def call private def card - @card ||= OpenProject::Common::WorkPackageCardComponent.new(work_package:, menu_src:) + @card ||= OpenProject::Common::WorkPackageCardComponent.new( + work_package:, + menu_src:, + **@system_arguments + ) end def before_render 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 5122de033ed0..186bf63e0117 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 @@ -111,11 +111,11 @@ def merge_drag_and_drop_data! 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) - } + sortable_lists_target: "list", + sortable_lists_list_type: drag_and_drop.fetch(:list_type) + }.tap do |data| + data[:sortable_lists_list_id] = drag_and_drop[:list_id] if drag_and_drop[:list_id].present? + end 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 3197d1f1c5df..c165c5ca268d 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 @@ -30,12 +30,16 @@ module Backlogs class WorkPackageCardListItemComponent < OpenProject::Common::BorderBoxListComponent::WorkPackageItem - private - - def build_card - WorkPackageCardComponent.new(work_package:, menu_src:) + def card + @card ||= WorkPackageCardComponent.new( + work_package:, + menu_src:, + **card_arguments + ) end + private + def draggable? current_user.allowed_in_project?(:manage_sprint_items, project) end @@ -54,7 +58,7 @@ def uses_inbox_routes? !container.is_a?(Sprint) end - def drop_url + def move_url if uses_inbox_routes? url_helpers.move_project_backlogs_inbox_path(project, work_package, params) else @@ -80,10 +84,16 @@ def menu_src end 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: "op-backlogs-story", + tabindex: (0 if draggable?), + data: card_data + } + end + + def card_data + data = { story: true, controller: "backlogs--story", backlogs__story_id_value: work_package.id, @@ -91,14 +101,32 @@ 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 + + public + + def row_args + super.tap do |arguments| + next unless draggable? + + arguments[:draggable] = true + arguments.delete(:tabindex) + end end - def draggable_data + def row_data + return {} unless draggable? + { - 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 end diff --git a/modules/backlogs/app/controllers/backlogs/inbox_controller.rb b/modules/backlogs/app/controllers/backlogs/inbox_controller.rb index 6824e0792e17..a53f9b68f560 100644 --- a/modules/backlogs/app/controllers/backlogs/inbox_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/inbox_controller.rb @@ -128,13 +128,17 @@ def failure_response(reason) end def move_params - params.require(%i[target_id]) - params.permit(:position, :prev_id, :target_id) + params.require(%i[list_type]) + params.permit(:position, :prev_item_id, :list_type, :list_id) end def position_attributes - if move_params.has_key?(:prev_id) - { prev_id: move_params[:prev_id].to_i } + if move_params.has_key?(:prev_item_id) + if move_params[:prev_item_id].present? + { prev_id: move_params[:prev_item_id].to_i } + else + { position: 1 } + end elsif move_params.has_key?(:position) { position: move_params[:position].to_i } else diff --git a/modules/backlogs/app/controllers/backlogs/move.rb b/modules/backlogs/app/controllers/backlogs/move.rb index 377b31c12623..ef3dbe8d3347 100644 --- a/modules/backlogs/app/controllers/backlogs/move.rb +++ b/modules/backlogs/app/controllers/backlogs/move.rb @@ -36,17 +36,15 @@ module Move private def move_attributes_from_target - target_type, target_id = move_params[:target_id].split(":", 2) - - case target_type + case move_params[:list_type] when "sprint" - { backlog_bucket_id: nil, sprint_id: target_id } + { backlog_bucket_id: nil, sprint_id: move_params[:list_id] } when "backlog_bucket" - { backlog_bucket_id: target_id, sprint_id: nil } + { backlog_bucket_id: move_params[:list_id], sprint_id: nil } when "inbox" { backlog_bucket_id: nil, sprint_id: nil } else - raise ArgumentError, "target_type must be one of: backlog_bucket, sprint, inbox." + raise ArgumentError, "list_type must be one of: backlog_bucket, sprint, inbox." 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 c83e38273460..a055b56865c9 100644 --- a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb @@ -170,13 +170,17 @@ def load_story end def move_params - params.require(%i[target_id]) - params.permit(:position, :prev_id, :target_id) + params.require(%i[list_type]) + params.permit(:position, :prev_item_id, :list_type, :list_id) end def position_attributes - if move_params.has_key?(:prev_id) - { prev_id: move_params[:prev_id].to_i } + if move_params.has_key?(:prev_item_id) + if move_params[:prev_item_id].present? + { prev_id: move_params[:prev_item_id].to_i } + else + { position: 1 } + end elsif move_params.has_key?(:position) { position: move_params[:position].to_i } else 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 fb11550a79e7..ba4afd33ec0d 100644 --- a/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb @@ -28,11 +28,10 @@ See COPYRIGHT and LICENSE files for more details. ++# %> <%= turbo_frame_tag "backlogs_container", refresh: :morph, class: "op-backlogs-page" do %> -
      -
      +
      +
      <%= render Backlogs::BacklogComponent.new( inbox_work_packages: @inbox_work_packages, @@ -41,8 +40,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/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index 31999c1b46fb..b446a3592fb2 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -119,9 +119,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 @@ -132,15 +132,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_inbox_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_inbox_path(project, work_package)) + expect(row["draggable"]).to eq("true") + end + + expect(rendered_component).to have_css(".op-backlogs-story") 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 @@ -170,14 +177,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-backlogs-story") 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 @@ -231,8 +238,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-backlogs-story[data-sortable-lists--item-id-value]") + expect(rendered_component).to have_no_css(".op-backlogs-story[data-sortable-lists--item-target]") + expect(rendered_component).to have_no_css(".op-backlogs-story[data-sortable-lists--item-move-url-value]") + expect(rendered_component).to have_no_css(".op-backlogs-story[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 09c17f7c472b..a89cb440bfe3 100644 --- a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb @@ -64,9 +64,10 @@ 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 + expect(box["data-target-allowed-drag-type"]).to be_nil end end @@ -145,10 +146,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_sprint_dialog_component_spec.rb b/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb index df420c62f34e..27cff48e2c8f 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 @@ -93,11 +93,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 @@ -130,7 +131,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 9fafa5ff4e7a..a56bb11f82e8 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -102,10 +102,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 @@ -115,11 +114,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, sprint, work_package1)) + expect(row["draggable"]).to eq("true") + end + + expect(rendered_component).to have_css(".Box-row#work_package_#{work_package1.id} .op-backlogs-story") 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 @@ -130,7 +136,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, sprint, work_package1, all: "1")) end end @@ -152,8 +158,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-backlogs-story[data-sortable-lists--item-id-value]") + expect(rendered_component).to have_no_css(".op-backlogs-story[data-sortable-lists--item-target]") + expect(rendered_component).to have_no_css(".op-backlogs-story[data-sortable-lists--item-move-url-value]") + expect(rendered_component).to have_no_css(".op-backlogs-story[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_list_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb index d9246595ff8a..3b4c1ee61b1d 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-backlogs-story[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-backlogs-story") 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 @@ -263,23 +270,23 @@ 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) + expect(box["data-target-allowed-drag-type"]).to be_nil 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 ba0f64609bd5..7489eab5b489 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, sprint, 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,8 +96,10 @@ 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[:tabindex]).to eq(0) end end end @@ -107,9 +107,12 @@ describe "URL derivation by container" do context "with a sprint container" do it "uses sprint routes" do - expect(item.row_args.dig(:data, :backlogs__story_split_url_value)) + render_inline(item.card) + card = page.find(".op-backlogs-story") + + 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, sprint, work_package)) end end @@ -118,7 +121,7 @@ let(:container) { backlog_bucket } it "uses inbox routes" do - 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_inbox_path(project, work_package)) end end @@ -127,7 +130,7 @@ let(:container) { "inbox_project_#{project.id}" } it "uses inbox routes" do - 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_inbox_path(project, work_package)) end end @@ -135,9 +138,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-backlogs-story") + + 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 +156,27 @@ expect(rendered_card).to have_css(".sr-only", text: "5 story points") end + it "wires the card as a Backlogs story" do + expect(rendered_card).to have_css( + ".op-backlogs-story[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-backlogs-story[data-controller~='backlogs--story']" + ) + expect(rendered_card).to have_no_css(".op-backlogs-story[data-controller~='sortable-lists--item']") + expect(rendered_card).to have_no_css(".op-backlogs-story[data-sortable-lists--item-move-url-value]") + expect(rendered_card).to have_no_css(".op-backlogs-story[draggable='true']") + end + it "supports caller-provided metric content through the item" do item.with_metric { "Custom metric" } diff --git a/modules/backlogs/spec/controllers/backlogs/inbox_controller_spec.rb b/modules/backlogs/spec/controllers/backlogs/inbox_controller_spec.rb index 1f8506b10470..71fff252ceca 100644 --- a/modules/backlogs/spec/controllers/backlogs/inbox_controller_spec.rb +++ b/modules/backlogs/spec/controllers/backlogs/inbox_controller_spec.rb @@ -141,16 +141,18 @@ describe "PUT #move" do let(:sprint) { create(:sprint, name: "Sprint 1", project:) } - let(:target_id) { "sprint:#{sprint.id}" } - let(:prev_id) { 1 } + let(:list_type) { "sprint" } + let(:list_id) { sprint.id } + let(:prev_item_id) { 1 } subject do put :move, params: { project_id: project.id, id: work_package.id, - target_id:, - prev_id: + list_type:, + list_id:, + prev_item_id: }, format: :turbo_stream end @@ -169,8 +171,9 @@ end context "when reordering within the Inbox" do - let(:target_id) { "inbox" } - let(:prev_id) { work_packages.first.id } + let(:list_type) { "inbox" } + let(:list_id) { nil } + let(:prev_item_id) { work_packages.first.id } it "replaces only the inbox component without a flash", :aggregate_failures do expect(response).to be_successful @@ -191,7 +194,7 @@ context "when no prev_id is provided" do let!(:work_packages) { create_list(:work_package, 5, project:, sprint:) } - let(:prev_id) { nil } + let(:prev_item_id) { nil } it "places the work package at the top of the sprint" do expect { work_package.reload } @@ -209,16 +212,18 @@ end let!(:work_packages) { create_list(:work_package, 5, project:) } - let(:target_id) { "inbox" } - let(:prev_id) { work_packages.first.id } + let(:list_type) { "inbox" } + let(:list_id) { nil } + let(:prev_item_id) { work_packages.first.id } subject do put :move, params: { project_id: project.id, id: work_package.id, - target_id:, - prev_id:, + list_type:, + list_id:, + prev_item_id:, all: "1" }, format: :turbo_stream 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 e4ed01a29d3e..7253c6cbeb99 100644 --- a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb +++ b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb @@ -116,8 +116,9 @@ project_id: project.id, sprint_id: sprint.id, id: story_in_sprint.id, - target_id: "sprint:#{other_sprint.id}", - prev_id: nil + list_type: "sprint", + list_id: other_sprint.id, + prev_item_id: nil }, format: :turbo_stream @@ -142,8 +143,8 @@ project_id: project.id, sprint_id: sprint.id, id: story_in_sprint.id, - target_id: "inbox", - prev_id: existing_inbox_item.id + list_type: "inbox", + prev_item_id: existing_inbox_item.id }, format: :turbo_stream @@ -172,8 +173,8 @@ project_id: project.id, sprint_id: sprint.id, id: story_in_sprint.id, - target_id: "inbox", - prev_id: existing_inbox_item.id, + list_type: "inbox", + prev_item_id: existing_inbox_item.id, all: "1" }, format: :turbo_stream @@ -201,7 +202,8 @@ project_id: project.id, sprint_id: sprint.id, id: story_in_sprint.id, - target_id: "sprint:#{other_sprint.id}", + list_type: "sprint", + list_id: other_sprint.id, position: 1 }, format: :turbo_stream diff --git a/modules/backlogs/spec/features/backlogs/edit_spec.rb b/modules/backlogs/spec/features/backlogs/edit_spec.rb index 1376abc86b00..21c2594156e0 100644 --- a/modules/backlogs/spec/features/backlogs/edit_spec.rb +++ b/modules/backlogs/spec/features/backlogs/edit_spec.rb @@ -176,10 +176,10 @@ planning_page.click_in_sprint_story_move_menu(work_package, "Move to sprint") within("#move-to-sprint-dialog") do - expect(page).to have_no_select("target_id", with_options: [first_sprint.name]) - expect(page).to have_select("target_id", with_options: [second_sprint.name]) + 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: "target_id" + select second_sprint.name, from: "list_id" click_on "Move" end diff --git a/modules/backlogs/spec/features/inbox_column_spec.rb b/modules/backlogs/spec/features/inbox_column_spec.rb index 68991f078e62..0c3ecbc26850 100644 --- a/modules/backlogs/spec/features/inbox_column_spec.rb +++ b/modules/backlogs/spec/features/inbox_column_spec.rb @@ -89,7 +89,8 @@ planning_page.expect_inbox_blankslate planning_page.expect_backlog_blankslate planning_page.expect_backlog_blankslate_description( - "To start planning your sprint, create one here or go to the project settings to receive sprints from a different project." + "To start planning your sprint, create one here or go to the project settings " \ + "to receive sprints from a different project." ) planning_page.expect_backlog_settings_link planning_page.expect_new_sprint_button @@ -274,9 +275,9 @@ within("#move-to-sprint-dialog") 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 @@ -290,8 +291,8 @@ planning_page.click_in_inbox_move_menu(inbox_wp1, "Move to sprint") within("#move-to-sprint-dialog") 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! @@ -311,7 +312,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_inbox_item_to_sprint(inbox_wp1, sprint) planning_page.expect_no_inbox_item(inbox_wp1) @@ -328,7 +329,13 @@ planning_page.expect_story_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], @@ -399,7 +406,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:) } @@ -431,13 +438,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 @@ -455,7 +462,7 @@ # Move an inbox item to the sprint via the dialog planning_page.click_in_inbox_move_menu(inbox_items.last, "Move to sprint") within("#move-to-sprint-dialog") 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 a8a005b342d9..ea8c260de97d 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) } @@ -118,6 +118,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/native_drag_probe_spec.rb b/modules/backlogs/spec/features/work_packages/native_drag_probe_spec.rb new file mode 100644 index 000000000000..9774885a2fd7 --- /dev/null +++ b/modules/backlogs/spec/features/work_packages/native_drag_probe_spec.rb @@ -0,0 +1,335 @@ +# 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" +require "json" + +RSpec.describe "Backlogs native drag probe", :js, :selenium do + create_shared_association_defaults_for_work_package_factory + + before do + skip "Set BACKLOGS_NATIVE_DND_PROBE=1 to run this diagnostic spec" unless ENV["BACKLOGS_NATIVE_DND_PROBE"] == "1" + end + + let(:attempts) { Integer(ENV.fetch("BACKLOGS_NATIVE_DND_ATTEMPTS", "10"), 10) } + let(:mode) { ENV.fetch("BACKLOGS_NATIVE_DND_MODE", "edge") } + + let!(:project) do + create(:project, enabled_module_names: %w[work_package_tracking backlogs]) + end + let!(:bucket) { create(:backlog_bucket, project:, name: "Native drag probe bucket") } + let!(:work_packages) do + Array.new(4) do |index| + create(:work_package, project:, backlog_bucket: bucket, position: index + 1) + end + end + + current_user do + create(:user, + member_with_permissions: { + project => %i[view_sprints view_work_packages create_sprints manage_sprint_items edit_work_packages] + }) + end + + it "reports whether native Selenium drag reaches Pragmatic DnD consistently" do + failures = [] + + attempts.times do |attempt| + reset_bucket_order! + visit project_backlogs_backlog_path(project) + expect(page).to have_css(bucket_selector) + + before_order = item_ids_in_bucket + source_id, = before_order + target_id = before_order.fetch(2) + + source_metadata = element_metadata(item_selector(source_id)) + target_metadata = element_metadata(item_selector(target_id)) + + install_drag_event_probe + action_metadata = native_drag(source_id:, target_id:) + + expected_order = before_order[1..2].insert(1, source_id) + before_order[3..] + matched = bucket_order_matches?(expected_order) + after_order = item_ids_in_bucket + event_log = drag_event_log + + unless matched + failures << { + attempt: attempt + 1, + mode:, + source_id:, + target_id:, + before_order:, + expected_order:, + after_order:, + source_metadata:, + target_metadata:, + action_metadata:, + event_counts: event_counts(event_log), + last_events: event_log.last(20) + } + end + ensure + stop_drag_event_probe + end + + expect(failures).to be_empty, JSON.pretty_generate(failures) + end + + def native_drag(source_id:, target_id:) + source = find(item_selector(source_id)) + target = find(item_selector(target_id)) + source_rect = source.native.rect + target_rect = target.native.rect + action_metadata = { + source_rect: rect_data(source_rect), + target_rect: rect_data(target_rect) + } + + scroll_to_element(source) + + case mode + when "element" + action_metadata[:action] = "drag_and_drop" + page.driver.browser.action.drag_and_drop(source.native, target.native).perform + when "offset" + action_metadata.merge!( + action: "drag_and_drop_by", + offset_x: target_rect.x - source_rect.x, + offset_y: target_rect.y - source_rect.y + ) + + page + .driver + .browser + .action + .drag_and_drop_by( + source.native, + action_metadata.fetch(:offset_x), + action_metadata.fetch(:offset_y) + ) + .perform + when "edge" + target_offset_y = -(target.native.rect.height / 2) + 6 + action_metadata.merge!( + action: "click_hold_move_to_edge_release", + target_offset_x: 0, + target_offset_y: + ) + + page + .driver + .browser + .action + .move_to(source.native) + .click_and_hold(source.native) + .perform + + sleep 0.2 + + page + .driver + .browser + .action + .move_to(target.native, 0, target_offset_y) + .perform + + sleep 0.2 + + page + .driver + .browser + .action + .release + .perform + else + raise ArgumentError, "Unknown BACKLOGS_NATIVE_DND_MODE=#{mode.inspect}; use edge, element, or offset" + end + + action_metadata + end + + def element_metadata(selector) + page.evaluate_script(<<~JS, selector) + (() => { + const element = document.querySelector(arguments[0]); + + if (!element) { + return { found: false, selector: arguments[0] }; + } + + const rect = element.getBoundingClientRect(); + const draggable = element.closest('[draggable="true"]'); + const row = element.closest('.Box-row'); + + return { + found: true, + selector: arguments[0], + tagName: element.tagName, + className: element.className, + draggableAttribute: element.getAttribute('draggable'), + isDraggableProperty: element.draggable, + closestDraggableTagName: draggable?.tagName ?? null, + closestDraggableClassName: draggable?.className ?? null, + closestDraggableItemId: draggable + ?.getAttribute('data-sortable-lists--item-id-value') ?? null, + 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) + } + }; + })() + JS + end + + def rect_data(rect) + { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + } + end + + def install_drag_event_probe + page.execute_script(<<~JS) + window.__backlogsNativeDndProbeAbort?.abort(); + + const controller = new AbortController(); + const events = []; + const types = [ + 'mousedown', + 'mousemove', + 'mouseup', + 'dragstart', + 'dragenter', + 'dragover', + 'dragleave', + 'drop', + 'dragend' + ]; + + 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 dropPositions() { + return Array + .from(document.querySelectorAll('[data-drop-position]')) + .map((element) => ({ + itemId: itemIdFor(element), + position: element.getAttribute('data-drop-position') + })); + } + + function pushEvent(event) { + 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, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + draggingCount: document.querySelectorAll('[data-dragging]').length, + honeyPotCount: document.querySelectorAll('[data-pdnd-honey-pot]').length, + dropPositions: dropPositions(), + time: Math.round(performance.now()) + }); + + if (events.length > 500) { + events.shift(); + } + } + + types.forEach((type) => { + document.addEventListener(type, pushEvent, { signal: controller.signal }); + }); + + window.__backlogsNativeDndProbeAbort = controller; + window.__backlogsNativeDndProbeEvents = events; + JS + end + + def stop_drag_event_probe + page.execute_script("window.__backlogsNativeDndProbeAbort?.abort();") + end + + def drag_event_log + page.evaluate_script("window.__backlogsNativeDndProbeEvents || []") + end + + def event_counts(event_log) + event_log.group_by { |event| event.fetch("type") }.transform_values(&:count) + end + + def item_ids_in_bucket + page.all("#{bucket_selector} [data-sortable-lists--item-id-value]", minimum: work_packages.length).map do |element| + element["data-sortable-lists--item-id-value"] + end + end + + def reset_bucket_order! + work_packages.each_with_index do |work_package, index| + work_package.reload.update!(backlog_bucket: bucket, position: index + 1) + end + end + + def bucket_order_matches?(expected_order) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 3 + + loop do + return true if item_ids_in_bucket == expected_order + return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + + sleep 0.05 + end + end + + def item_selector(work_package_id) + "#{test_selector("work-package-#{work_package_id}")}[data-sortable-lists--item-id-value]" + end + + def bucket_selector + test_selector("backlog-bucket-#{bucket.id}") + end +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/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index 294a0f702c06..0b0afaa21b8b 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 @@ -99,7 +100,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) @@ -124,13 +125,46 @@ 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 end + def pick_up_and_release_work_package(work_package) + moved_element = find(draggable_work_package_selector(work_package)) + + install_backlogs_move_request_probe + pick_up_and_release_backlogs_item(moved_element) + rescue Capybara::Cuprite::ObsoleteNode + retry + 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 expect_work_package_not_draggable(work_package) expect(page) .to have_no_css(draggable_work_package_selector(work_package)) @@ -221,7 +255,7 @@ def expect_inbox_show_more end def expect_no_inbox_show_more - wait_for_network_idle + wait_for_backlogs_network_idle within_inbox do expect(page).to have_no_css("#inbox_project_#{project.id}_show_more") end @@ -231,7 +265,7 @@ def click_inbox_show_more within_inbox do find("#inbox_project_#{project.id}_show_more").click end - wait_for_network_idle + wait_for_backlogs_network_idle end def open_sprint_story_details(story) @@ -248,7 +282,7 @@ def expect_inbox_items_in_order(*work_packages) expect(page).to have_css(selectors.join(" + ")) end - wait_for_network_idle + wait_for_backlogs_network_idle end def within_inbox_menu(work_package, &) @@ -272,7 +306,9 @@ def click_in_inbox_move_menu(work_package, item_name) end menu = open_controlled_menu(button) submenu = open_move_submenu(menu) - submenu.find(:menuitem, text: item_name).click + wait_for_backlogs_turbo_stream do + submenu.find(:menuitem, text: item_name).click + end end def within_sprint_story_menu(story, &) @@ -296,15 +332,18 @@ def click_in_sprint_story_move_menu(story, item_name) end menu = open_controlled_menu(button) submenu = open_move_submenu(menu) - submenu.find(:menuitem, text: item_name).click + wait_for_backlogs_turbo_stream do + submenu.find(:menuitem, text: item_name).click + end end def drag_inbox_item_to_sprint(work_package, sprint) moved_element = find(draggable_work_package_selector(work_package)) target_element = find(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 + expect_no_inbox_item(work_package) rescue Capybara::Cuprite::ObsoleteNode retry end @@ -312,7 +351,8 @@ def drag_inbox_item_to_sprint(work_package, sprint) def drag_sprint_item_to_inbox(work_package) moved_element = find(draggable_work_package_selector(work_package)) target_element = find("#inbox_project_#{project.id}") - moved_element.native.drag_to(target_element.native, delay: 0.1) + drag_backlogs_item(source: moved_element, target: target_element) + wait_for { work_package.reload.sprint_id }.to be_nil rescue Capybara::Cuprite::ObsoleteNode retry end @@ -426,19 +466,22 @@ def drag_work_package_to_backlog_bucket(work_package, bucket) target_element = find(bucket_selector(bucket)) wait_for_turbo_stream do - moved_element.native.drag_to(target_element.native, delay: 0.1) + 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 def drag_work_package_to_backlog_inbox(work_package) moved_element = find(draggable_work_package_selector(work_package)) - target_element = find(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 rescue Capybara::Cuprite::ObsoleteNode retry end @@ -484,7 +527,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 @@ -511,7 +554,7 @@ def expect_details_view(story) expect(page).to have_current_path project_backlogs_backlog_details_path(story.project, story), ignore_query: true - wait_for_network_idle + wait_for_backlogs_network_idle details_view end @@ -657,7 +700,416 @@ 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 + + clear_pragmatic_dnd_honey_pot + expect(page).to have_no_css("[data-pdnd-honey-pot]", wait: 2, visible: :all) + 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 + + clear_pragmatic_dnd_honey_pot + expect(page).to have_no_css("[data-pdnd-honey-pot]", wait: 2, visible: :all) + 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(timeout: 10, &) + return wait_for_turbo_stream(timeout:, &) if using_cuprite? + + 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/package-lock.json b/package-lock.json index 6a27a9ab135c..3c9824c06dd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5", "@xeokit/xeokit-gltf-to-xkt": "^1.3.1" }, "devDependencies": { @@ -19,6 +20,27 @@ "npm": "^10.1.0" } }, + "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/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -48,7 +70,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -815,6 +836,12 @@ "ajv": "4.11.8 - 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/call-me-maybe": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", @@ -1803,6 +1830,12 @@ } ] }, + "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/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", diff --git a/package.json b/package.json index efc58d163c47..3ac5df3ae9d3 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@redocly/cli": "^2.17.0" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.5", "@xeokit/xeokit-gltf-to-xkt": "^1.3.1" } } 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 66778329f507..3836a3d411f0 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 @@ -125,5 +125,19 @@ expect(rendered).to have_element "include-fragment", src: "/slot-menu" expect(rendered).to have_no_element "include-fragment", src: menu_src 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 diff --git a/spec/support/authentication_helpers.rb b/spec/support/authentication_helpers.rb index 855dc0a13d35..826d3f059d44 100644 --- a/spec/support/authentication_helpers.rb +++ b/spec/support/authentication_helpers.rb @@ -68,7 +68,12 @@ def login_with(login, password, autologin: false, visit_signin_path: true) end click_button I18n.t(:button_login), type: "submit" + end + + if using_cuprite? wait_for_network_idle + else + expect(page).to have_no_test_selector("user-login--form", wait: 10) end end diff --git a/spec/support/edit_fields/edit_field.rb b/spec/support/edit_fields/edit_field.rb index 0fd11b4d7a57..4a429d926176 100644 --- a/spec/support/edit_fields/edit_field.rb +++ b/spec/support/edit_fields/edit_field.rb @@ -272,7 +272,7 @@ def update(value, save: !create_form?, expect_failure: false) # an attribute, which may cause an input not to open properly. retry_block do activate_edition - wait_for_network_idle + wait_for_network_idle if using_cuprite? set_value value # select fields are saved on change diff --git a/spec/support/pages/page.rb b/spec/support/pages/page.rb index c471324f1e56..b3faed2a6a2c 100644 --- a/spec/support/pages/page.rb +++ b/spec/support/pages/page.rb @@ -53,7 +53,7 @@ def visit! visit(path) - wait_for_reload + wait_for_reload if using_cuprite? end def reload!