From d951587d311670c1d7eb0264e5442630a08a3e87 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 10 Jul 2026 11:11:07 +0200 Subject: [PATCH] fix: dragging an item into a different shopping-list category didn't work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each category group had its own isolated DndContext, so dnd-kit had no way to resolve a drop that landed in a different group — cross-category drags were silently no-ops by construction, not an edge-case bug. Lifted to a single DndContext wrapping all groups, with one handleDragEnd that looks up both the dragged item's and the drop target's current group from live state: same group reorders as before, different group changes the item's aisle (appended after the item it was dropped on) via the existing changeCategory persistence path. Co-Authored-By: Claude Sonnet 5 --- .../meal-plan/shopping-list-view.tsx | 138 +++++++++++------- 1 file changed, 86 insertions(+), 52 deletions(-) diff --git a/apps/web/components/meal-plan/shopping-list-view.tsx b/apps/web/components/meal-plan/shopping-list-view.tsx index c3f52b0..177b1fe 100644 --- a/apps/web/components/meal-plan/shopping-list-view.tsx +++ b/apps/web/components/meal-plan/shopping-list-view.tsx @@ -65,6 +65,12 @@ type Item = { type SortMode = "category" | "alpha" | "unchecked"; +// Sentinel group key for items with no aisle (the "Other" bucket) — using a stable, +// locale-independent key (rather than the translated "Other" text) as the group +// identifier avoids that identifier changing across locales and lets it double as a +// dnd-kit droppable/group key. +const OTHER_KEY = "__other__"; + export function ShoppingListView({ listId, initialItems, @@ -81,6 +87,7 @@ export function ShoppingListView({ const [movingToPantry, setMovingToPantry] = useState(false); const [query, setQuery] = useState(""); const [sortMode, setSortMode] = useState("category"); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [autoCategorizing, setAutoCategorizing] = useState(false); @@ -232,22 +239,48 @@ export function ShoppingListView({ } } - function handleGroupDragEnd(groupItems: Item[], event: DragEndEvent) { + function groupKeyOf(item: Item): string { + return item.aisle ?? OTHER_KEY; + } + + // Single handler for the whole list (not per-group) — a per-group DndContext can + // only ever resolve drops within its own group, which is exactly why cross-category + // dragging didn't work before. Determines both the dragged item's and the drop + // target's group from the live `items` state, so it naturally supports moving + // an item into a different category, not just reordering within one. + function handleDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id) return; - const oldIndex = groupItems.findIndex((i) => i.id === active.id); - const newIndex = groupItems.findIndex((i) => i.id === over.id); - if (oldIndex === -1 || newIndex === -1) return; - const reordered = arrayMove(groupItems, oldIndex, newIndex); - const reorderedIds = new Set(reordered.map((i) => i.id)); - setItems((prev) => { - // Splice the reordered group items back into their original positions within - // the full list, leaving every other item untouched. - let cursor = 0; - return prev.map((i) => (reorderedIds.has(i.id) ? reordered[cursor++]! : i)); - }); - void persistOrder(reordered); + const activeItem = items.find((i) => i.id === active.id); + const overItem = items.find((i) => i.id === over.id); + if (!activeItem || !overItem) return; + + const sourceKey = groupKeyOf(activeItem); + const destKey = groupKeyOf(overItem); + + if (sourceKey === destKey) { + // Reordering within the same category — same as before. + const groupItems = items.filter((i) => groupKeyOf(i) === sourceKey); + const oldIndex = groupItems.findIndex((i) => i.id === active.id); + const newIndex = groupItems.findIndex((i) => i.id === over.id); + if (oldIndex === -1 || newIndex === -1) return; + const reordered = arrayMove(groupItems, oldIndex, newIndex); + const reorderedIds = new Set(reordered.map((i) => i.id)); + + setItems((prev) => { + let cursor = 0; + return prev.map((i) => (reorderedIds.has(i.id) ? reordered[cursor++]! : i)); + }); + void persistOrder(reordered); + return; + } + + // Dropped on an item in a DIFFERENT category — move it there (appended after the + // item it was dropped on). Reuses changeCategory for the persisted aisle update; + // exact insertion position within the destination group is a nice-to-have, not + // required to unblock "can't move an item to another category" at all. + void changeCategory(activeItem, overItem.aisle); } const filtered = useMemo(() => { @@ -284,12 +317,16 @@ export function ShoppingListView({ const grouped = showGroups ? filtered.reduce>((acc, item) => { - const key = item.aisle ?? t("aisleOther"); + const key = groupKeyOf(item); (acc[key] ??= []).push(item); return acc; }, {}) : {}; + function groupLabel(key: string): string { + return key === OTHER_KEY ? tShopping("aisleOther") : categoryLabel(key); + } + return (
@@ -337,22 +374,25 @@ export function ShoppingListView({ {filtered.length === 0 ? (

{t("noSearchResults")}

) : showGroups ? ( - Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => ( - 1} - readOnly={readOnly} - categoryOptions={categoryOptions} - categoryLabel={categoryLabel} - tShopping={tShopping} - onToggle={toggleItem} - onChangeCategory={changeCategory} - onDeleteRequest={setDeleteTarget} - onDragEnd={(event) => handleGroupDragEnd(aisleItems, event)} - /> - )) + +
+ {Object.entries(grouped).sort(([a], [b]) => groupLabel(a).localeCompare(groupLabel(b))).map(([key, groupItems]) => ( + 1} + readOnly={readOnly} + categoryOptions={categoryOptions} + categoryLabel={categoryLabel} + tShopping={tShopping} + onToggle={toggleItem} + onChangeCategory={changeCategory} + onDeleteRequest={setDeleteTarget} + /> + ))} +
+
) : ( void; onChangeCategory: (item: Item, category: string | null) => void; onDeleteRequest: (item: Item) => void; - onDragEnd: (event: DragEndEvent) => void; }) { - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); - return (
{showHeader && (

{aisleLabel}

)}
- - i.id)} strategy={verticalListSortingStrategy}> - {items.map((item) => ( - - ))} - - + i.id)} strategy={verticalListSortingStrategy}> + {items.map((item) => ( + + ))} +
);