#106 — Drag-and-Drop zwischen Kategorien in der Einkaufsliste #109
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#109
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.tsxrendert pro Kategorie-Sektion einen eigenen,unabhängigen
DndContext(renderCategorySection) — das war schon in#81's Design-Dokument als bewussteVereinfachung 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:
DndContext, sodass einProdukt aus einer Sektion in eine andere gezogen werden kann.
MoveShoppingProductCommandmit der Ziel-Kategorie und derZiel-Position auf (gleicher Befehl, den das bestehende "..."-Menü/der Picker heute schon nutzt).
(
ReorderShoppingProductsCommand/bestehende Logik unverändert).für Tastatur-/Screenreader-Nutzung, die kein Drag-and-Drop durchführen können) — Drag-and-Drop ist eine
Ergänzung, kein Ersatz.
#107"nur Kategorien mitEinträgen anzeigen") muss weiterhin möglich sein — leere Kategorien dürfen durch
#107nicht alsDrop-Ziel verschwinden, nur aus der reinen Lese-Ansicht.
Out of scope for this story:
design (
106_shopping_cross_category_drag_and_drop_design.md)Design note —
#106Drag-and-Drop zwischen KategorienNo backend or DB changes.
MoveShoppingProductCommandalready accepts an arbitrary targetcategoryId+sortOrderand is already category-agnostic on the server side —ShoppingCategoryPicker(the "..." menu) has called it with a different category since
#81. This story is purely aboutgiving the drag gesture a second way to reach the same already-authorized command; no new attack
surface, no new endpoint.
Shared
DndContextShoppingListPage.tsx'srenderCategorySectioncurrently mounts one independentDndContextpercategory (
#81's deliberate simplification, noted in this story's own background section). Replacedwith a single page-level
DndContextwrapping every category section, each still holding its ownSortableContext(items={categoryProducts.map(p => p.id)}) for the existing same-categoryreorder behaviour (AC 3, unchanged logic —
arrayMove+ renumber-via-refetch stays exactly as-is).Empty categories as drop targets
A
SortableContextalone gives you a drop target only where there's already an item to collidewith — an empty category has none. Each category section is additionally wrapped in a
useDroppable({id: categoryContainerId(categoryId)})container (dnd-kit's standard "multiplecontainers" 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.
#107filtersorderedCategoriesdown to categories that currently hold an active product, so anempty category isn't rendered at all in the normal read view — nothing to physically drop onto.
Per AC 5 ("dürfen durch
#107nicht als Drop-Ziel verschwinden, nur aus der reinen Lese-Ansicht"),ShoppingListPagenow tracks whether a drag is in progress (onDragStart/onDragEnd/onDragCanceltoggling onedraggingProductIdstate) and, only while dragging, renders everycategory sorted by
sortOrder— including currently-empty ones, shown with a small dashed "Drophere" placeholder instead of a product list. The instant the drag ends, the view reverts to
#107'sfiltered list; an empty category that received nothing during the drag never appears outside a
drag gesture.
Cross-category move computation
New pure function
computeCrossCategoryMoveinReactUi/src/utils/reorder.ts, unit-testeddirectly instead of simulating real dnd-kit pointer events in jsdom — same testing approach this
file's existing
computeReorder/computeReorderCorealready established for same-list reorder.Given the current
productsByCategorygrouping plus dnd-kit'sactive.id/over.id, it resolves:overproduct, or, ifoveris acontainer id, that category directly,
sortOrderto send the backend: thetarget item's index when dropped on a specific product, or
2147483647(Int32.MaxValue) whendropped on a container/empty space — "append at the end", the same convention already used by
ShoppingCategoryPicker.tsx/CategoryPicker.tsx/PantryCategoryPicker.tsxfor exactly thiscase, clamped server-side.
ShoppingListPage'sonDragEndcalls this, optimistically moves the product between the two localbuckets (setting its
categoryId) when the target differs from the source, callsmoveShoppingProduct(same call already used for same-category reorder and the picker), thenrefetches — unchanged reconciliation pattern, since sibling renumbering in two categories now
isn't reflected in the single-product response either.
Accessibility (AC 4)
ShoppingCategoryPickerand the "..." menu are untouched — still the only way to move a productfor 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.