[#73473] Make whole Backlog card draggable#22838
Conversation
There was a problem hiding this comment.
Pull request overview
This PR modernizes Backlogs drag-and-drop to support whole-card dragging (instead of a dedicated handle) and reduces server-rendered Turbo Stream updates for same-list reorders to improve responsiveness.
Changes:
- Replaces the Backlogs DnD surface/list implementation (new Stimulus controllers) and mounts it in the backlog view.
- Removes the dedicated drag handle from story/inbox cards and updates styling for whole-card dragging.
- Optimizes reorder endpoints to return
204 No Contentfor same-list reorders and updates Ruby/JS test coverage accordingly.
Reviewed changes
Copilot reviewed 24 out of 25 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| modules/backlogs/app/views/backlogs/backlog/_backlog_list.html.erb | Mounts backlogs--dnd-surface and removes generic-drag-and-drop wiring. |
| modules/backlogs/app/controllers/backlogs/work_packages_controller.rb | Returns 204 for same-sprint reorder and avoids unnecessary Turbo Stream replacements. |
| modules/backlogs/app/controllers/backlogs/inbox_controller.rb | Returns 204 for inbox reorders to rely on optimistic client updates. |
| modules/backlogs/app/components/backlogs/sprint_component.rb | Switches sprint drop target metadata to the new list controller data attributes. |
| modules/backlogs/app/components/backlogs/inbox_component.rb | Switches inbox drop target metadata to the new list controller data attributes. |
| modules/backlogs/app/components/backlogs/story_component.html.erb | Removes the dedicated drag handle markup. |
| modules/backlogs/app/components/backlogs/inbox_item_component.* | Removes dedicated drag handle markup and renames story controller metadata to item controller metadata. |
| frontend/src/stimulus/controllers/dynamic/backlogs/dnd-surface.controller.ts | New DnD surface controller using @dnd-kit/dom, optimistic DOM move + persistence. |
| frontend/src/stimulus/controllers/dynamic/backlogs/dnd-list.controller.ts | New list metadata helper controller. |
| frontend/src/stimulus/controllers/dynamic/backlogs/item.controller.ts | Renames story controller to item controller; adds pointer-move threshold to suppress click-after-drag. |
| frontend/src/assets/sass/backlogs/_master_backlog.sass | Updates card grid layout to remove drag handle column/area. |
| frontend/src/global_styles/primer/_overrides.sass | Updates draggable cursor styling. |
| frontend/package.json / frontend/package-lock.json | Adds @dnd-kit/dom dependency. |
| modules/backlogs/spec/** and frontend/src/**.spec.ts | Updates/extends request/controller/component/unit specs for the new behavior. |
Files not reviewed (1)
- frontend/package-lock.json: Language not supported
Comments suppressed due to low confidence (1)
modules/backlogs/app/components/backlogs/inbox_item_component.rb:77
row_optionsalways setsBox-row--draggableand the draggable metadata (data-draggable-id,data-drop-url, etc.) regardless ofdraggable?. With the new whole-card drag behavior (no dedicated handle), this makes inbox rows draggable even for users who are not allowed to manage sprint items. Please gate the draggable class + draggable metadata behinddraggable?(similar toSprintComponent#draggable_item_config) so unauthorized users cannot start DnD from the inbox UI.
def row_options
{
id: dom_id(work_package),
classes: "Box-row--hover-blue Box-row--focus-gray Box-row--clickable Box-row--draggable",
data: {
draggable_id: work_package.id,
draggable_type: "story",
drop_url: move_project_backlogs_inbox_path(project, work_package),
story: true,
controller: "backlogs--item",
backlogs__item_id_value: work_package.id,
backlogs__item_split_url_value: project_backlogs_backlog_details_path(project, work_package),
backlogs__item_full_url_value: work_package_path(work_package),
backlogs__item_selected_class: "Box-row--blue",
test_selector: card_test_selector
},
tabindex: 0
}
72ad902 to
a9d61b2
Compare
a9d61b2 to
187d91d
Compare
187d91d to
7402cb3
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
modules/backlogs/app/components/backlogs/inbox_item_component.html.erb:35
- Same as for StoryComponent: removing the drag handle eliminates the explicit labelled drag control (focusable button with aria-label), which is an accessibility regression for keyboard/screen reader users. Consider retaining an accessible drag handle (optionally visually hidden or shown on focus) while still supporting handle-less pointer dragging.
<% container.with_row(**row_options) do %>
<%= grid_layout("op-backlogs-story", tag: :article) do |grid| %>
<% grid.with_area(:info_line) do %>
<%= render(WorkPackages::InfoLineComponent.new(work_package:)) %>
<% end %>
<% grid.with_area(:points) do %>
| <%= grid_layout("op-backlogs-story", tag: :article) do |grid| %> | ||
| <% grid.with_area(:drag_handle, classes: "hide-when-print") do %> | ||
| <%= if draggable? | ||
| render( | ||
| Primer::OpenProject::DragHandle.new( | ||
| classes: "op-backlogs-story--drag_handle_button", | ||
| label: t(".label_drag_story", name: story.subject) | ||
| ) | ||
| ) | ||
| end %> | ||
| <% end %> | ||
|
|
||
| <% grid.with_area(:info_line) do %> | ||
| <%= render(WorkPackages::InfoLineComponent.new(work_package: story)) %> | ||
| <% end %> |
There was a problem hiding this comment.
Removing the dedicated drag handle also removes the only explicit, labelled control for drag-and-drop (previously a focusable button with an aria-label). In handle-less mode, mouse dragging works, but assistive-technology and keyboard users lose a clear affordance/announcement that the item is draggable. Consider keeping a drag handle button for accessibility (it can be visually hidden / only shown on focus) while still allowing handle-less dragging for pointer users.
7402cb3 to
4184ce5
Compare
HDinger
left a comment
There was a problem hiding this comment.
Works nicely 🤩 👍 I only have some minor remarks
9bba79c to
b3dce16
Compare
Introduces `handle-value` Boolean supporting Drag and Drop without a separate drag handle. The drag handle selector can still be customized via the separate `handle-selector-value`. Also introduces `isInteractiveElement` and `closestInteractiveElement` helpers - to prevent drag and drop being initiated from interactive elements (e.g. an ActionMenu on a work package card). https://community.openproject.org/wp/73473
Removes the dedicated `DragHandle` column from Backlog story and inbox rows and makes the whole card the drag surface, opting in via `handle-value="false"` on the drag-and-drop controller. Permission gating is preserved: `InboxItemComponent` now matches the `SprintComponent` pattern and emits `Box-row--draggable` along with the `data-draggable-*` / `data-drop-url` attributes only when the current user has `:manage_sprint_items`. Users without the permission see a plain, non-draggable row. Drops the now-unused `StoryComponent#draggable?`, the `drag_handle` grid column, and the `label_drag_*` translation keys. https://community.openproject.org/wp/73473
b3dce16 to
fcb0bd5
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 20 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
modules/backlogs/app/components/backlogs/inbox_item_component.html.erb:35
- With the drag handle removed, this row no longer contains any labelled control indicating it can be dragged. Since dragula-based drag-and-drop is pointer-driven, consider adding an accessible affordance (e.g., a visually-hidden drag handle/button with an aria-label or row-level ARIA description) so screen reader users still get a clear draggable announcement.
<% container.with_row(**row_options) do %>
<%= grid_layout("op-backlogs-story", tag: :article) do |grid| %>
<% grid.with_area(:info_line) do %>
<%= render(WorkPackages::InfoLineComponent.new(work_package:)) %>
<% end %>
<% grid.with_area(:points) do %>
| @@ -28,17 +28,6 @@ See COPYRIGHT and LICENSE files for more details. | |||
| ++# %> | |||
|
|
|||
| <%= grid_layout("op-backlogs-story", tag: :article) do |grid| %> | |||
There was a problem hiding this comment.
Removing the dedicated drag handle means there is no longer any labelled, focusable element that communicates “this card is draggable” to assistive technologies. In handle-less mode this can be a UX/a11y regression because dragging becomes pointer-only with no explicit announcement/instructions. Consider adding an accessible affordance (e.g., a visually-hidden drag handle/button with an aria-label, or an aria-describedby/roledescription on the draggable row) while still allowing pointer dragging from the whole card.
| <%= grid_layout("op-backlogs-story", tag: :article) do |grid| %> | |
| <%= grid_layout( | |
| "op-backlogs-story", | |
| tag: :article, | |
| id: dom_target(story), | |
| tabindex: 0, | |
| "aria-roledescription": t(".drag_roledescription", default: "draggable story card"), | |
| "aria-describedby": dom_target(story, :drag_description) | |
| ) do |grid| %> | |
| <span id="<%= dom_target(story, :drag_description) %>" | |
| style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;"> | |
| <%= t(".drag_description", default: "Draggable story card. Drag and drop to reorder.") %> | |
| </span> |
The previous guard `unless before || into || (before && into)` was effectively `unless before || into`, so passing both arguments was silently accepted despite the message promising "either/or". Replaces the guard with an XOR check and corrects the error string. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Wires up `generic-drag-and-drop.controller` so the CSS3 grabbing cursor shows for the duration of a drag, by toggling a `data-dragging="active"` attribute on `document.body` and styling descendants via the `body[data-dragging="active"]` rule. Also clears the body attribute in `disconnect()`, so a Turbo morph while a drag is in progress does not leave the cursor stuck globally. https://community.openproject.org/wp/74194
Adds an optional `mirrorContainer` target to the drag-and-drop controller. When present, the dragula mirror (drag preview) is inserted into that element instead of `document.body`, so it inherits the container's padding and alignment. The Backlog inbox wires its own list element as both `container` and `mirrorContainer`, which keeps the dragged card visually aligned with the rest of the inbox rather than jumping to the page edge. https://community.openproject.org/wp/74195
Tags the dragula mirror with `data-dragging="mirror"` and the source row with `data-dragging="source"`, then adds Primer overrides so the mirror reads as a distinct floating card (background, border, shadow) while the source row renders as a faded "ghost" at reduced opacity. Also suppresses `.Box-row--hover-*` colors during a drag to stop hover flicker as the mirror passes over the list.
fcb0bd5 to
88b2ea2
Compare
| // A Turbo morph mid-drag can replace the element tree without the | ||
| // dragend event firing, so clear the body-level cursor flag defensively. | ||
| document.body.removeAttribute('data-dragging'); |
There was a problem hiding this comment.
Claude found this pre-emptively. I wouldn't have considered it.
ulferts
left a comment
There was a problem hiding this comment.
I only browsed over the code but I did test the code out this morning which worked like a charm. I trust that Henriette did the code review. Together, we should be fine.
Ticket
https://community.openproject.org/wp/73473
https://community.openproject.org/wp/74194
https://community.openproject.org/wp/74195
What are you trying to accomplish?
Make the entire Backlog work-package card the drag surface, in both the sprint backlogs and the inbox. Previously a dedicated
DragHandlegrabber on the left of each card was the only drag affordance. This PR removes the handle and lets users grab the card anywhere to reorder it or move it between sprints.Permission gating is preserved: users without
:manage_sprint_itemsstill render non-draggable cards (noBox-row--draggableclass, nodata-draggable-*/data-drop-urlattributes) and cannot initiate a drag.Screenshots
What approach did you choose and why?
Two additions to the existing Dragula-based
generic-drag-and-dropStimulus controller make it work without a dedicated handle:handle-value(Boolean, defaulttrue). Whenfalse, the whole element is the drag surface andhandle-selector-valueis ignored. A Boolean was preferred over interpreting an emptyhandle-selector-value, since empty HTML attributes are ambiguous.isInteractiveElement/closestInteractiveElementhelpers, used on drag start to abort the gesture when the user is interacting with a button, link, input, or ActionMenu inside the card — so the kebab menu and WP detail links aren't hijacked.Server-side,
Backlogs::InboxItemComponentnow gates theBox-row--draggableclass and thedata-draggable-*/data-drop-urlattributes behind:manage_sprint_items, matching the pattern already used inSprintComponent. Removing the handle also lets us drop thedrag_handlegrid column, thelabel_drag_*translation keys, and the now-unusedStoryComponent#draggable?.Merge checklist