Content
View differences
Updated by Alexander Coles 6 days ago
## Context
Migrating Backlogs from dragula to Atlassian Pragmatic DnD (#AGILE-251). Open question: what controller topology should the reusable `sortable-lists` Stimulus primitive take, given upcoming requirements — #AGILE-181 (multi-select + batch move), #AGILE-175 (draggable buckets/sprints), #AGILE-278 (bulk move action), #AGILE-284 (optimistic reordering).
`sortable-lists` lives in generic `frontend/src/stimulus/controllers/dynamic/` — a reusable primitive, not backlogs-specific. Backlogs is its first consumer, so API quality matters beyond Backlogs.
## Options considered
- **A — 2 controllers** (current branch): root `SortableListsController` owns the monitor, per-list drop targets, and the optimistic move/persist/rollback transaction; per-card `ItemController`. A list is a *target* of the root. Item↔root via DOM walk (`closest`) + reading a `data-…-accepted-type` attr.
- **B — 3 controllers via outlets** (experiment PR https://github.com/opf/openproject/pull/23739): adds a first-class `ListController`; the root coordinates `list` + `item` leaves via Stimulus outlets. Each leaf list owns the Pragmatic DnD primitive for its own DOM surface; drop target + per-list values; the root pushes shared state down and reads its children as a live collection.
- **C — 1 monolith** (the original `generic-drag-and-drop` topology, re-done in Pragmatic): one controller owns everything via targets.
## Topology illustration

Pragmatic DnD primitives: **[M]** Monitor (`monitorForElements` — one per surface, runs the actual drop) · **[D]** Drop Target (`dropTargetForElements`) · **[G]** Draggable (`draggable`).
Primitive ownership per topology:
| Primitive | A (2 ctrl) | B (3 ctrl) | C (monolith) |
|---|---|---|---|
| Monitor | root | root | one controller |
| Drop Target — list | root | **list** | one controller |
| Drop Target — card/item card edge | item | item | one controller |
| Draggable — card/item card | item | item | one controller |
## Important Pragmatic DnD constraint
A Pragmatic DnD drop target is keyed by its DOM `element`. Registering two element drop targets on Under B, the same DOM future "both list and item" element is not (a Sprint) simply stacks both controllers — `data-controller="… list … item"` — giving it a supported composition model: the adapter warns that the drop target is already registered Drop Target (accepts cards) and a Draggable (reorderable within its registry stores one entry per element.
That means section) with no new machinery. B is the earlier shorthand “Sprint / Bucket = `--item --list` on only topology where each primitive is co-located with the same element” is not valid when both controllers register `dropTargetForElements`. controller that owns it.
## Resolved model
No recursion of controllers (Stimulus targets cannot be assigned to a specific instance except by DOM ancestry). Instead: a **single root per page** = the whole interaction surface; every draggable/droppable surface element carries a **type**; a domain object an element can be **both a list and an item**, but item** (a Sprint is draggable as `sprint` and a DOM element must have **at most one Pragmatic DnD drop target**. container accepting `work_package`). Drop acceptance = `accepts(target.acceptedType, dragData.type)`, per-list and multi-valued.
That “both "both list and item” item" model remains composition, but composition is *composition*, expressed through distinct DOM surfaces, not two drop targets on one element. Default shape: as stacked `data-controller` values:
| Domain concept / surface Element | controller role controllers |
|---|---|
| Work-package card | `--item` |
| Sprint / Bucket reorder surface | `--item` `--item --list` |
| Sprint / Bucket contained-work-packages surface | `--list` |
| Section (sprints, backlog) | `--list` |
Example shape for a future Sprint/Bucket that is both reorderable and accepts Work package cards:
```html
<li data-controller="sortable-lists--item" data-sortable-lists--item-type-value="sprint">
<section data-controller="sortable-lists--list" data-sortable-lists--list-type-value="sprint">
... work package cards ...
</section>
</li>
```
A coordinated composite drop-target controller remains possible, but the default recommendation is separate nested or sibling DOM surfaces because it keeps Pragmatic DnD ownership unambiguous.
Drop acceptance should move from a root-wide scalar to per-target, multi-valued configuration:
- a Work package card item edge accepts `work_package` when reordering cards;
- a Sprint list surface accepts `work_package` when moving cards into the Sprint;
- a Sprint item surface accepts `sprint` when reordering Sprints;
- a Backlog bucket item surface accepts `backlog_bucket` when reordering buckets.
## Re-evaluation of options
- **C rejected**: cannot express per-element opt-in to draggable/droppable without becoming draggable/droppable; becomes a type-dispatch matrix that grows with selection + batch + container drag.
- **A viable but weaker**: it can be made to work for current parity, but per-list config and future dual-role surfaces push it (now multi accepted-types) degrades into stringly DOM attributes; "both" is asymmetric (root-target + item-controller). Adequate for parity only; drifts toward manual DOM bookkeeping. It does not provide B as the child registry needed for root-owned selection and broadcast state. requirements land.
- **B recommended, with a constraint**: recommended**: leaf controllers compose and compose, localize per-surface per-element config as typed values, and give the root gets the child registry **child registry** it needs. The constraint needs to broadcast state and drive multi-select. A sync `closest()` lookup is child→parent only and cannot provide that “both list and item” means distinct DOM surfaces or a deliberate composite, never two independent `dropTargetForElements` registrations on the same element. collection.
## Recommendation (not a final decision)
Adopt **B** (single root + first-class `list`/`item` leaf controllers via outlets), plus:
- enforce a **single-drop-target-per-DOM-element invariant**;
- represent dual-role Backlogs concepts with separate item/list DOM surfaces by default;
- move `acceptedType` to leaf targets as a multi-valued accepted-types config;
- keep becomes per-list and multi-valued; the root as coordinator for shared state, selection, monitor, move orchestration, and holds a `type → moveUrlTemplate` resolution. map resolved by dragged type at drop.
Rationale: both upcoming requirements still push toward first-class leaves + a coordinator that can enumerate its children. The revised B shape keeps those benefits while respecting Pragmatic DnD’s element-keyed drop-target registry. is the cheapest path that does not require rework when #AGILE-175 / #AGILE-181 land.
## Open questions
- Exact markup shape for #AGILE-175: nested item/list surfaces, sibling surfaces, or a purpose-built composite controller.
- Ordering rule for the N selected within the target (selection order vs source Position).
- Partial-failure / atomicity of the batch move (#AGILE-278).
- Multi-card drag preview (“N cards”) ("N cards") behaviour.
- Confirm no turbo-stream re-renders the root *element* itself (would drop the in-memory selection `Set`).
- Whether #AGILE-175 / #AGILE-181 belong in the generic `sortable-lists` primitive or in backlogs-specific adapters.
Migrating Backlogs from dragula to Atlassian Pragmatic DnD (#AGILE-251). Open question: what controller topology should the reusable `sortable-lists` Stimulus primitive take, given upcoming requirements — #AGILE-181 (multi-select + batch move), #AGILE-175 (draggable buckets/sprints), #AGILE-278 (bulk move action), #AGILE-284 (optimistic reordering).
`sortable-lists` lives in generic `frontend/src/stimulus/controllers/dynamic/` — a reusable primitive, not backlogs-specific. Backlogs is its first consumer, so API quality matters beyond Backlogs.
## Options considered
- **A — 2 controllers** (current branch): root `SortableListsController` owns the monitor, per-list drop targets, and the optimistic move/persist/rollback transaction; per-card `ItemController`. A list is a *target* of the root. Item↔root via DOM walk (`closest`) + reading a `data-…-accepted-type` attr.
- **B — 3 controllers via outlets** (experiment PR https://github.com/opf/openproject/pull/23739): adds a first-class `ListController`; the root coordinates `list` + `item` leaves via Stimulus outlets. Each leaf
- **C — 1 monolith** (the original `generic-drag-and-drop` topology, re-done in Pragmatic): one controller owns everything via targets.
## Topology illustration

Pragmatic DnD primitives: **[M]** Monitor (`monitorForElements` — one per surface, runs the actual drop) · **[D]** Drop Target (`dropTargetForElements`) · **[G]** Draggable (`draggable`).
Primitive ownership per topology:
| Primitive | A (2 ctrl) | B (3 ctrl) | C (monolith) |
|---|---|---|---|
| Monitor | root | root | one controller |
| Drop Target — list | root | **list** | one controller |
| Drop Target — card/item
| Draggable — card/item
## Important Pragmatic DnD constraint
A Pragmatic DnD drop target is keyed by its DOM `element`. Registering two element drop targets on
That means
## Resolved model
No recursion of controllers (Stimulus targets cannot be assigned to a specific instance except by DOM ancestry). Instead: a **single root per page** = the whole interaction surface; every draggable/droppable surface
That “both
| Domain concept / surface
|---|---|
| Work-package card | `--item` |
| Sprint / Bucket reorder surface | `--item`
| Sprint / Bucket contained-work-packages surface | `--list` |
| Section (sprints, backlog) | `--list` |
Example shape for a future Sprint/Bucket that is both reorderable and accepts Work package cards:
```html
<li data-controller="sortable-lists--item" data-sortable-lists--item-type-value="sprint">
<section data-controller="sortable-lists--list" data-sortable-lists--list-type-value="sprint">
... work package cards ...
</section>
</li>
```
A coordinated composite drop-target controller remains possible, but the default recommendation is separate nested or sibling DOM surfaces because it keeps Pragmatic DnD ownership unambiguous.
Drop acceptance should move from a root-wide scalar to per-target, multi-valued configuration:
- a Work package card item edge accepts `work_package` when reordering cards;
- a Sprint list surface accepts `work_package` when moving cards into the Sprint;
- a Sprint item surface accepts `sprint` when reordering Sprints;
- a Backlog bucket item surface accepts `backlog_bucket` when reordering buckets.
## Re-evaluation of options
- **C rejected**: cannot express per-element opt-in to draggable/droppable without becoming
- **A viable but weaker**: it can be made to work for current parity, but per-list config and future dual-role surfaces push it
- **B recommended, with a constraint**:
## Recommendation (not a final decision)
Adopt **B** (single root + first-class `list`/`item` leaf controllers via outlets), plus:
- enforce a **single-drop-target-per-DOM-element invariant**;
- represent dual-role Backlogs concepts with separate item/list DOM surfaces by default;
- move `acceptedType` to leaf targets as a multi-valued accepted-types config;
- keep
Rationale:
## Open questions
- Exact markup shape for #AGILE-175: nested item/list surfaces, sibling surfaces, or a purpose-built composite controller.
- Ordering rule for the N selected within the target (selection order vs source Position).
- Partial-failure / atomicity of the batch move (#AGILE-278).
- Multi-card drag preview (“N cards”)
- Confirm no turbo-stream re-renders the root *element* itself (would drop the in-memory selection `Set`).
- Whether #AGILE-175 / #AGILE-181 belong in the generic `sortable-lists` primitive or in backlogs-specific adapters.