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 list owns its 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`).
```text
A — 2 controllers (current branch)
sortable-lists root ───── controller: sortable-lists [M]
└─ list (target of root) ───── drop target owned by root [D]
└─ card ───── controller: item [G][D]
B — 3 controllers via outlets (recommended)
sortable-lists root ───── controller: sortable-lists [M]
│ ↕ outlets
├─ list ───── controller: list [D]
│ └─ card ───── controller: item [G][D]
C — 1 monolith
generic root ───── controller: generic-drag-and-drop [M][D][G]
├─ container (target) ───── drop target via target [D]
└─ item (target) ───── draggable + drop target via targets [G][D]
```
Primitive ownership per topology:
<figure class="table op-uc-figure_align-center op-uc-figure"><table class="op-uc-table"><thead class="op-uc-table--head"><tr class="op-uc-table--row"><th class="op-uc-table--cell op-uc-table--cell_head"><p class="op-uc-p">Primitive</p></th><th class="op-uc-table--cell op-uc-table--cell_head"><p class="op-uc-p">A (2 ctrl)</p></th><th class="op-uc-table--cell op-uc-table--cell_head"><p class="op-uc-p">B (3 ctrl)</p></th><th class="op-uc-table--cell op-uc-table--cell_head"><p class="op-uc-p">C (monolith)</p></th></tr></thead><tbody><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Monitor</p></td><td class="op-uc-table--cell"><p class="op-uc-p">root</p></td><td class="op-uc-table--cell"><p class="op-uc-p">root</p></td><td class="op-uc-table--cell"><p class="op-uc-p">one controller</p></td></tr><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Drop Target — list</p></td><td class="op-uc-table--cell"><p class="op-uc-p">root</p></td><td class="op-uc-table--cell"><p class="op-uc-p"><strong>list</strong></p></td><td class="op-uc-table--cell"><p class="op-uc-p">one controller</p></td></tr><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Drop Target — card edge</p></td><td class="op-uc-table--cell"><p class="op-uc-p">item</p></td><td class="op-uc-table--cell"><p class="op-uc-p">item</p></td><td class="op-uc-table--cell"><p class="op-uc-p">one controller</p></td></tr><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Draggable — card</p></td><td class="op-uc-table--cell"><p class="op-uc-p">item</p></td><td class="op-uc-table--cell"><p class="op-uc-p">item</p></td><td class="op-uc-table--cell"><p class="op-uc-p">one controller</p></td></tr></tbody></table></figure>
Under B, the future "both list and item" element (a Sprint) simply stacks both controllers — `data-controller="… list … item"` — giving it a Drop Target (accepts cards) and a Draggable (reorderable within its section) with no new machinery. B is the only topology where each primitive is co-located with the 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 element carries a **type**; an element can be **both a list and an item** (a Sprint is draggable as `sprint` and a drop container accepting `work_package`). Drop acceptance = `accepts(target.acceptedType, dragData.type)`, per-list and multi-valued.
That "both list and item" model is _composition_, expressed as stacked `data-controller` values:
<figure class="table op-uc-figure_align-center op-uc-figure"><table class="op-uc-table"><thead class="op-uc-table--head"><tr class="op-uc-table--row"><th class="op-uc-table--cell op-uc-table--cell_head"><p class="op-uc-p">Element</p></th><th class="op-uc-table--cell op-uc-table--cell_head"><p class="op-uc-p">controllers</p></th></tr></thead><tbody><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Work-package card</p></td><td class="op-uc-table--cell"><p class="op-uc-p"><code class="op-uc-code">--item</code></p></td></tr><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Sprint / Bucket</p></td><td class="op-uc-table--cell"><p class="op-uc-p"><code class="op-uc-code">--item --list</code></p></td></tr><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Section (sprints, backlog)</p></td><td class="op-uc-table--cell"><p class="op-uc-p"><code class="op-uc-code">--list</code></p></td></tr></tbody></table></figure>
* **C rejected**: cannot express per-element opt-in to draggable/droppable; becomes a type-dispatch matrix that grows with selection + batch + container drag.
* **A viable but weaker**: per-list config (now multi accepted-types) degrades into stringly DOM attributes; "both" is asymmetric (root-target + item-controller). Adequate for parity only; drifts toward B as the requirements land.
* **B recommended**: leaf controllers compose, localize per-element config as typed values, and give the root the **child registry** it needs to broadcast state and drive multi-select. A sync `closest()` lookup is child→parent only and cannot provide that collection.
## Recommendation (not a final decision)
Adopt **B** (single root + first-class `list`/`item` leaf controllers via outlets), plus: `acceptedType` becomes per-list and multi-valued; the root holds a `type → moveUrlTemplate` map resolved by dragged type at drop.
Rationale: both upcoming requirements push toward first-class leaves + a coordinator that can enumerate its children. B is the cheapest path that does not require rework when #AGILE-175 / #AGILE-181 land.
## Open questions
* 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") 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 list owns its 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`).
```text
A — 2 controllers (current branch)
sortable-lists root ───── controller: sortable-lists [M]
└─ list (target of root) ───── drop target owned by root [D]
└─ card ───── controller: item [G][D]
B — 3 controllers via outlets (recommended)
sortable-lists root ───── controller: sortable-lists [M]
│ ↕ outlets
├─ list ───── controller: list [D]
│ └─ card ───── controller: item [G][D]
C — 1 monolith
generic root ───── controller: generic-drag-and-drop [M][D][G]
├─ container (target) ───── drop target via target [D]
└─ item (target) ───── draggable + drop target via targets [G][D]
```
Primitive ownership per topology:
<figure class="table op-uc-figure_align-center op-uc-figure"><table class="op-uc-table"><thead class="op-uc-table--head"><tr class="op-uc-table--row"><th class="op-uc-table--cell op-uc-table--cell_head"><p class="op-uc-p">Primitive</p></th><th class="op-uc-table--cell op-uc-table--cell_head"><p class="op-uc-p">A (2 ctrl)</p></th><th class="op-uc-table--cell op-uc-table--cell_head"><p class="op-uc-p">B (3 ctrl)</p></th><th class="op-uc-table--cell op-uc-table--cell_head"><p class="op-uc-p">C (monolith)</p></th></tr></thead><tbody><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Monitor</p></td><td class="op-uc-table--cell"><p class="op-uc-p">root</p></td><td class="op-uc-table--cell"><p class="op-uc-p">root</p></td><td class="op-uc-table--cell"><p class="op-uc-p">one controller</p></td></tr><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Drop Target — list</p></td><td class="op-uc-table--cell"><p class="op-uc-p">root</p></td><td class="op-uc-table--cell"><p class="op-uc-p"><strong>list</strong></p></td><td class="op-uc-table--cell"><p class="op-uc-p">one controller</p></td></tr><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Drop Target — card edge</p></td><td class="op-uc-table--cell"><p class="op-uc-p">item</p></td><td class="op-uc-table--cell"><p class="op-uc-p">item</p></td><td class="op-uc-table--cell"><p class="op-uc-p">one controller</p></td></tr><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Draggable — card</p></td><td class="op-uc-table--cell"><p class="op-uc-p">item</p></td><td class="op-uc-table--cell"><p class="op-uc-p">item</p></td><td class="op-uc-table--cell"><p class="op-uc-p">one controller</p></td></tr></tbody></table></figure>
Under B, the future "both list and item" element (a Sprint) simply stacks both controllers — `data-controller="… list … item"` — giving it a Drop Target (accepts cards) and a Draggable (reorderable within its section) with no new machinery. B is the only topology where each primitive is co-located with the 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 element carries a **type**; an element can be **both a list and an item** (a Sprint is draggable as `sprint` and a drop container accepting `work_package`). Drop acceptance = `accepts(target.acceptedType, dragData.type)`, per-list and multi-valued.
That "both list and item" model is _composition_, expressed as stacked `data-controller` values:
<figure class="table op-uc-figure_align-center op-uc-figure"><table class="op-uc-table"><thead class="op-uc-table--head"><tr class="op-uc-table--row"><th class="op-uc-table--cell op-uc-table--cell_head"><p class="op-uc-p">Element</p></th><th class="op-uc-table--cell op-uc-table--cell_head"><p class="op-uc-p">controllers</p></th></tr></thead><tbody><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Work-package card</p></td><td class="op-uc-table--cell"><p class="op-uc-p"><code class="op-uc-code">--item</code></p></td></tr><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Sprint / Bucket</p></td><td class="op-uc-table--cell"><p class="op-uc-p"><code class="op-uc-code">--item --list</code></p></td></tr><tr class="op-uc-table--row"><td class="op-uc-table--cell"><p class="op-uc-p">Section (sprints, backlog)</p></td><td class="op-uc-table--cell"><p class="op-uc-p"><code class="op-uc-code">--list</code></p></td></tr></tbody></table></figure>
* **C rejected**: cannot express per-element opt-in to draggable/droppable; becomes a type-dispatch matrix that grows with selection + batch + container drag.
* **A viable but weaker**: per-list config (now multi accepted-types) degrades into stringly DOM attributes; "both" is asymmetric (root-target + item-controller). Adequate for parity only; drifts toward B as the requirements land.
* **B recommended**: leaf controllers compose, localize per-element config as typed values, and give the root the **child registry** it needs to broadcast state and drive multi-select. A sync `closest()` lookup is child→parent only and cannot provide that collection.
## Recommendation (not a final decision)
Adopt **B** (single root + first-class `list`/`item` leaf controllers via outlets), plus: `acceptedType` becomes per-list and multi-valued; the root holds a `type → moveUrlTemplate` map resolved by dragged type at drop.
Rationale: both upcoming requirements push toward first-class leaves + a coordinator that can enumerate its children. B is the cheapest path that does not require rework when #AGILE-175 / #AGILE-181 land.
## Open questions
* 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") 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.