#106 — Drag-and-Drop zwischen Kategorien in der Einkaufsliste #109

Closed
opened 2026-08-18 13:14:08 +02:00 by lena · 1 comment
lena commented 2026-08-18 13:14:08 +02:00 (Migrated from git.butzei.de)

Story: Drag-and-Drop zwischen Kategorien in der Einkaufsliste

As a Nutzer der Einkaufsliste,
I want to ein Produkt per Drag-and-Drop nicht nur innerhalb seiner Kategorie umsortieren, sondern auch in
eine andere Kategorie ziehen können,
so that ich ein falsch einsortiertes Produkt nicht über das "..."-Menü verschieben muss, sondern es direkt
dorthin ziehen kann, wo es hingehört.

Hintergrund/verifiziert im Code: ShoppingListPage.tsx rendert pro Kategorie-Sektion einen eigenen,
unabhängigen DndContext (renderCategorySection) — das war schon in #81's Design-Dokument als bewusste
Vereinfachung festgehalten ("cross-category reassignment is a dropdown ... rather than a literal
cross-container drag"). Ein Produkt in eine andere Kategorie zu verschieben geht heute ausschließlich über
MoveShoppingProductCommand, ausgelöst über das "..."-Menü einer Produktzeile (ShoppingCategoryPicker).

Acceptance criteria:

  • Alle Kategorie-Sektionen einer Einkaufsliste teilen sich einen gemeinsamen DndContext, sodass ein
    Produkt aus einer Sektion in eine andere gezogen werden kann.
  • Loslassen in einer anderen Kategorie ruft MoveShoppingProductCommand mit der Ziel-Kategorie und der
    Ziel-Position auf (gleicher Befehl, den das bestehende "..."-Menü/der Picker heute schon nutzt).
  • Umsortieren innerhalb derselben Kategorie per Drag-and-Drop bleibt wie bisher funktionsfähig
    (ReorderShoppingProductsCommand/bestehende Logik unverändert).
  • Das bestehende "..."-Menü mit manueller Kategorie-Auswahl bleibt zusätzlich bestehen (Zugänglichkeit
    für Tastatur-/Screenreader-Nutzung, die kein Drag-and-Drop durchführen können) — Drag-and-Drop ist eine
    Ergänzung, kein Ersatz.
  • Ein Drop auf eine leere Kategorie (aktuell ausgeblendet, siehe Story #107 "nur Kategorien mit
    Einträgen anzeigen") muss weiterhin möglich sein — leere Kategorien dürfen durch #107 nicht als
    Drop-Ziel verschwinden, nur aus der reinen Lese-Ansicht.

Out of scope for this story:

  • Drag-and-drop zwischen verschiedenen Einkaufslisten (nur innerhalb einer Liste).
  • Mehrfachauswahl/Batch-Drag mehrerer Produkte gleichzeitig.
# Story: Drag-and-Drop zwischen Kategorien in der Einkaufsliste **As a** Nutzer der Einkaufsliste, **I want to** ein Produkt per Drag-and-Drop nicht nur innerhalb seiner Kategorie umsortieren, sondern auch in eine andere Kategorie ziehen können, **so that** ich ein falsch einsortiertes Produkt nicht über das "..."-Menü verschieben muss, sondern es direkt dorthin ziehen kann, wo es hingehört. **Hintergrund/verifiziert im Code:** `ShoppingListPage.tsx` rendert pro Kategorie-Sektion einen eigenen, unabhängigen `DndContext` (`renderCategorySection`) — das war schon in `#81`'s Design-Dokument als bewusste Vereinfachung festgehalten ("cross-category reassignment is a dropdown ... rather than a literal cross-container drag"). Ein Produkt in eine andere Kategorie zu verschieben geht heute ausschließlich über `MoveShoppingProductCommand`, ausgelöst über das "..."-Menü einer Produktzeile (`ShoppingCategoryPicker`). **Acceptance criteria:** - [ ] Alle Kategorie-Sektionen einer Einkaufsliste teilen sich einen gemeinsamen `DndContext`, sodass ein Produkt aus einer Sektion in eine andere gezogen werden kann. - [ ] Loslassen in einer anderen Kategorie ruft `MoveShoppingProductCommand` mit der Ziel-Kategorie und der Ziel-Position auf (gleicher Befehl, den das bestehende "..."-Menü/der Picker heute schon nutzt). - [ ] Umsortieren innerhalb derselben Kategorie per Drag-and-Drop bleibt wie bisher funktionsfähig (`ReorderShoppingProductsCommand`/bestehende Logik unverändert). - [ ] Das bestehende "..."-Menü mit manueller Kategorie-Auswahl bleibt zusätzlich bestehen (Zugänglichkeit für Tastatur-/Screenreader-Nutzung, die kein Drag-and-Drop durchführen können) — Drag-and-Drop ist eine Ergänzung, kein Ersatz. - [ ] Ein Drop auf eine leere Kategorie (aktuell ausgeblendet, siehe Story `#107` "nur Kategorien mit Einträgen anzeigen") muss weiterhin möglich sein — leere Kategorien dürfen durch `#107` nicht als Drop-Ziel verschwinden, nur aus der reinen Lese-Ansicht. **Out of scope for this story:** - Drag-and-drop zwischen verschiedenen Einkaufslisten (nur innerhalb einer Liste). - Mehrfachauswahl/Batch-Drag mehrerer Produkte gleichzeitig.
lena commented 2026-08-18 13:14:08 +02:00 (Migrated from git.butzei.de)

design (106_shopping_cross_category_drag_and_drop_design.md)

Design note — #106 Drag-and-Drop zwischen Kategorien

No backend or DB changes. MoveShoppingProductCommand already accepts an arbitrary target
categoryId + sortOrder and is already category-agnostic on the server side — ShoppingCategoryPicker
(the "..." menu) has called it with a different category since #81. This story is purely about
giving the drag gesture a second way to reach the same already-authorized command; no new attack
surface, no new endpoint.

Shared DndContext

ShoppingListPage.tsx's renderCategorySection currently mounts one independent DndContext per
category (#81's deliberate simplification, noted in this story's own background section). Replaced
with a single page-level DndContext wrapping every category section, each still holding its own
SortableContext (items={categoryProducts.map(p => p.id)}) for the existing same-category
reorder behaviour (AC 3, unchanged logic — arrayMove + renumber-via-refetch stays exactly as-is).

Empty categories as drop targets

A SortableContext alone gives you a drop target only where there's already an item to collide
with — an empty category has none. Each category section is additionally wrapped in a
useDroppable({id: categoryContainerId(categoryId)}) container (dnd-kit's standard "multiple
containers" pattern), so dropping in the empty space below the last item, or into a category with
zero items, still resolves to that category via the container id instead of a product id.

#107 filters orderedCategories down to categories that currently hold an active product, so an
empty category isn't rendered at all in the normal read view — nothing to physically drop onto.
Per AC 5 ("dürfen durch #107 nicht als Drop-Ziel verschwinden, nur aus der reinen Lese-Ansicht"),
ShoppingListPage now tracks whether a drag is in progress (onDragStart/onDragEnd/
onDragCancel toggling one draggingProductId state) and, only while dragging, renders every
category sorted by sortOrder — including currently-empty ones, shown with a small dashed "Drop
here" placeholder instead of a product list. The instant the drag ends, the view reverts to #107's
filtered list; an empty category that received nothing during the drag never appears outside a
drag gesture.

Cross-category move computation

New pure function computeCrossCategoryMove in ReactUi/src/utils/reorder.ts, unit-tested
directly instead of simulating real dnd-kit pointer events in jsdom — same testing approach this
file's existing computeReorder/computeReorderCore already established for same-list reorder.
Given the current productsByCategory grouping plus dnd-kit's active.id/over.id, it resolves:

  • the product's current category (source),
  • the target category — either the category owning the over product, or, if over is a
    container id, that category directly,
  • a target index for the optimistic local update, and the sortOrder to send the backend: the
    target item's index when dropped on a specific product, or 2147483647 (Int32.MaxValue) when
    dropped on a container/empty space — "append at the end", the same convention already used by
    ShoppingCategoryPicker.tsx/CategoryPicker.tsx/PantryCategoryPicker.tsx for exactly this
    case, clamped server-side.

ShoppingListPage's onDragEnd calls this, optimistically moves the product between the two local
buckets (setting its categoryId) when the target differs from the source, calls
moveShoppingProduct (same call already used for same-category reorder and the picker), then
refetches — unchanged reconciliation pattern, since sibling renumbering in two categories now
isn't reflected in the single-product response either.

Accessibility (AC 4)

ShoppingCategoryPicker and the "..." menu are untouched — still the only way to move a product
for keyboard/screen-reader users who can't perform a pointer drag. Drag-and-drop is additive.

Out of scope (per the story)

Cross-list drag and multi-select/batch drag are not touched.

**design** (`106_shopping_cross_category_drag_and_drop_design.md`) # Design note — `#106` Drag-and-Drop zwischen Kategorien No backend or DB changes. `MoveShoppingProductCommand` already accepts an arbitrary target `categoryId` + `sortOrder` and is already category-agnostic on the server side — `ShoppingCategoryPicker` (the "..." menu) has called it with a *different* category since `#81`. This story is purely about giving the drag gesture a second way to reach the same already-authorized command; no new attack surface, no new endpoint. ## Shared `DndContext` `ShoppingListPage.tsx`'s `renderCategorySection` currently mounts one independent `DndContext` per category (`#81`'s deliberate simplification, noted in this story's own background section). Replaced with a single page-level `DndContext` wrapping every category section, each still holding its own `SortableContext` (`items={categoryProducts.map(p => p.id)}`) for the existing same-category reorder behaviour (AC 3, unchanged logic — `arrayMove` + renumber-via-refetch stays exactly as-is). ## Empty categories as drop targets A `SortableContext` alone gives you a drop target only where there's already an item to collide with — an empty category has none. Each category section is additionally wrapped in a `useDroppable({id: categoryContainerId(categoryId)})` container (dnd-kit's standard "multiple containers" pattern), so dropping in the empty space below the last item, or into a category with zero items, still resolves to that category via the container id instead of a product id. `#107` filters `orderedCategories` down to categories that currently hold an active product, so an empty category isn't rendered at all in the normal read view — nothing to physically drop onto. Per AC 5 ("dürfen durch `#107` nicht als Drop-Ziel verschwinden, nur aus der reinen Lese-Ansicht"), `ShoppingListPage` now tracks whether a drag is in progress (`onDragStart`/`onDragEnd`/ `onDragCancel` toggling one `draggingProductId` state) and, only while dragging, renders *every* category sorted by `sortOrder` — including currently-empty ones, shown with a small dashed "Drop here" placeholder instead of a product list. The instant the drag ends, the view reverts to `#107`'s filtered list; an empty category that received nothing during the drag never appears outside a drag gesture. ## Cross-category move computation New pure function `computeCrossCategoryMove` in `ReactUi/src/utils/reorder.ts`, unit-tested directly instead of simulating real dnd-kit pointer events in jsdom — same testing approach this file's existing `computeReorder`/`computeReorderCore` already established for same-list reorder. Given the current `productsByCategory` grouping plus dnd-kit's `active.id`/`over.id`, it resolves: - the product's current category (source), - the target category — either the category owning the `over` product, or, if `over` is a container id, that category directly, - a target index for the optimistic local update, and the `sortOrder` to send the backend: the target item's index when dropped on a specific product, or `2147483647` (Int32.MaxValue) when dropped on a container/empty space — "append at the end", the same convention already used by `ShoppingCategoryPicker.tsx`/`CategoryPicker.tsx`/`PantryCategoryPicker.tsx` for exactly this case, clamped server-side. `ShoppingListPage`'s `onDragEnd` calls this, optimistically moves the product between the two local buckets (setting its `categoryId`) when the target differs from the source, calls `moveShoppingProduct` (same call already used for same-category reorder and the picker), then refetches — unchanged reconciliation pattern, since sibling renumbering in *two* categories now isn't reflected in the single-product response either. ## Accessibility (AC 4) `ShoppingCategoryPicker` and the "..." menu are untouched — still the only way to move a product for keyboard/screen-reader users who can't perform a pointer drag. Drag-and-drop is additive. ## Out of scope (per the story) Cross-list drag and multi-select/batch drag are not touched.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
robert/todo#109
No description provided.