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 owns the Pragmatic DnD primitive for its own DOM surface; 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 edge | item | item | one controller |
| Draggable — card/item | item | item | one controller |
## Key Important Pragmatic DnD constraint
A Pragmatic DnD drop target is keyed by its DOM `element`: registering `element`. Registering two `dropTargetForElements` element drop targets on the same DOM element is unsupported (the not a supported composition model: the adapter warns, warns that the drop target is already registered and its registry stores one entry per element). So element.
That means the earlier shorthand “Sprint / Bucket = `--item --list` on the same element” is not valid when both controllers register `dropTargetForElements`.
## 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 carries a **type**; a domain object can be **both a list and an item**, but that a DOM element must have **at most one Pragmatic DnD drop target**.
That “both list and item” model remains composition, but composition must be is expressed through **distinct distinct DOM surfaces**, surfaces, not two drop targets on one element. The shorthand "Sprint Default shape:
| Domain concept / surface | controller role |
|---|---|
| Work-package card | `--item` |
| Sprint / Bucket = `--item --list` on the same element" is therefore invalid. reorder surface | `--item` |
| Sprint / Bucket contained-work-packages surface | `--list` |
| Section (sprints, backlog) | `--list` |
Default dual-role Example shape (e.g. for a future Sprint Sprint/Bucket that is both reorderable *and* and accepts cards): 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 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 toward manual DOM bookkeeping. It does not provide the child registry needed for root-owned selection and broadcast state.
- **B recommended, with a constraint**: leaf controllers compose and localize per-surface config as typed values, and the root gets the child registry it needs. The constraint is that “both list and item” means distinct DOM surfaces or a deliberate composite, never two independent `dropTargetForElements` registrations on the same element.
## Recommendation (not a final decision)
Adopt **B** — single (single root coordinator + first-class `list`/`item` leaf controllers via outlets — with these constraints: outlets), plus:
- **One Pragmatic DnD drop target per DOM element.** Represent enforce a **single-drop-target-per-DOM-element invariant**;
- represent dual-role Backlogs concepts (Sprint, Bucket) as with separate nested/sibling item + list item/list DOM surfaces by default; a deliberate composite controller stays possible if one physical surface must do both.
- **Move move `acceptedType` to leaf targets** targets as multi-valued, per-target config (a card edge accepts `work_package`; a Sprint list surface accepts `work_package`; a Sprint item surface accepts `sprint`; a bucket item surface accepts `backlog_bucket`). multi-valued accepted-types config;
- **Keep keep the root as coordinator** coordinator for shared state, selection, the monitor, move orchestration, and `type → moveUrlTemplate` resolution.
Why B over A/C: the Rationale: upcoming requirements still push toward first-class leaves (per-surface typed config) plus + a coordinator that can enumerate its children for root-owned selection and broadcast — which A's DOM-walk + root-wide scalar can't give cleanly, and C collapses into a type-dispatch matrix that grows with selection/batch/container drag. children. The revised B shape keeps those benefits while respecting the Pragmatic DnD’s element-keyed drop-target registry.
## 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 owns the Pragmatic DnD primitive for its own DOM surface; 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`).
|---|---|---|---|
| Monitor | root | root | one controller |
| Drop Target — list | root | **list** | one controller |
| Drop Target — card/item edge | item | item | one controller |
| Draggable — card/item | item | item | one controller |
## Key
A
That means the earlier shorthand “Sprint / Bucket = `--item --list` on the same element” is not valid when both controllers register `dropTargetForElements`.
## Resolved model
No recursion of controllers (Stimulus targets cannot be assigned to
That “both list and item” model remains composition, but
| Domain concept
|---|---|
| Work-package card | `--item` |
| Sprint /
| Sprint / Bucket contained-work-packages surface | `--list` |
| Section (sprints, backlog) | `--list` |
Default dual-role
```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>
```
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.
- **C rejected**: cannot express per-element opt-in to draggable/droppable without becoming 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 toward manual DOM bookkeeping. It does not provide the child registry needed for root-owned selection and broadcast state.
- **B recommended, with a constraint**: leaf controllers compose and localize per-surface config as typed values, and the root gets the child registry it needs. The constraint is that “both list and item” means distinct DOM surfaces or a deliberate composite, never two independent `dropTargetForElements` registrations on the same element.
##
Adopt **B** — single
- **One Pragmatic DnD drop target per DOM element.** Represent
- represent
- **Move
- **Keep
Why B over A/C: the
## 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.