diff --git a/apps/web/components/meal-plan/shopping-list-view.tsx b/apps/web/components/meal-plan/shopping-list-view.tsx index 177b1fe..7f42b86 100644 --- a/apps/web/components/meal-plan/shopping-list-view.tsx +++ b/apps/web/components/meal-plan/shopping-list-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import { Check, Package, Loader2, GripVertical, MoreVertical, Trash2, Search, Sparkles } from "lucide-react"; @@ -39,6 +39,8 @@ import { guessAisle, GROCERY_CATEGORIES } from "@/lib/grocery-categories"; import { DndContext, DragEndEvent, + DragOverEvent, + DragStartEvent, PointerSensor, useSensor, useSensors, @@ -88,6 +90,10 @@ export function ShoppingListView({ const [query, setQuery] = useState(""); const [sortMode, setSortMode] = useState("category"); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + // Aisle the dragged item had when the drag started — captured so onDragEnd can tell + // whether the item actually changed category (and needs a persisted aisle update) as + // opposed to just being reordered within its original group. + const dragStartAisleRef = useRef(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [autoCategorizing, setAutoCategorizing] = useState(false); @@ -243,44 +249,96 @@ export function ShoppingListView({ 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) { + // Persists just the aisle for one item, without touching local state — used at drag + // end once the item has already visually settled into its destination group via + // onDragOver. Reverts on failure (only the field, not its position — a rare-case + // rollback, not worth re-deriving the exact prior array position for). + async function persistAisleChange(itemId: string, nextAisle: string | null, prevAisle: string | null) { + try { + const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${itemId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ aisle: nextAisle }), + }); + if (!res.ok) throw new Error(); + } catch { + setItems((prev) => prev.map((i) => i.id === itemId ? { ...i, aisle: prevAisle } : i)); + toast.error(tCommon("updateFailed")); + } + } + + function handleDragStart(event: DragStartEvent) { + const activeItem = items.find((i) => i.id === event.active.id); + dragStartAisleRef.current = activeItem?.aisle ?? null; + } + + // Live cross-category preview: without this, dragging over a different group's rows + // does nothing visually until drop, since dnd-kit only computes a transform for items + // that are actually present in the target group's array. Moving the item into the + // destination group's position in `items` as soon as it's dragged over makes it + // render there immediately, like any other multi-container dnd-kit board. Purely + // local — nothing is persisted until drag end. + function handleDragOver(event: DragOverEvent) { const { active, over } = event; if (!over || active.id === over.id) return; const activeItem = items.find((i) => i.id === active.id); const overItem = items.find((i) => i.id === over.id); if (!activeItem || !overItem) return; + if (groupKeyOf(activeItem) === groupKeyOf(overItem)) return; - const sourceKey = groupKeyOf(activeItem); - const destKey = groupKeyOf(overItem); + setItems((prev) => { + const withoutActive = prev.filter((i) => i.id !== activeItem.id); + const overIndex = withoutActive.findIndex((i) => i.id === overItem.id); + if (overIndex === -1) return prev; + const next = [...withoutActive]; + next.splice(overIndex, 0, { ...activeItem, aisle: overItem.aisle }); + return next; + }); + } - 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; + // 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. By the time this fires, onDragOver has already moved + // the item into its destination group locally, so this just finalizes the exact + // position within that group and persists whatever actually changed: the new order, + // and — only if the category actually changed since drag start — the new aisle. + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + const startAisle = dragStartAisleRef.current; + dragStartAisleRef.current = null; + + if (!over) { + // Dropped outside any droppable — onDragOver may have already moved the item + // into a different group locally (pure preview, never persisted); put it back + // rather than leaving a moved-but-unsaved item that only "resets" on refresh. + setItems((prev) => prev.map((i) => i.id === active.id ? { ...i, aisle: startAisle } : i)); + return; + } + + const activeItem = items.find((i) => i.id === active.id); + if (!activeItem) return; + + const destKey = groupKeyOf(activeItem); + const groupItems = items.filter((i) => groupKeyOf(i) === destKey); + const oldIndex = groupItems.findIndex((i) => i.id === active.id); + const newIndex = active.id === over.id + ? oldIndex + : groupItems.findIndex((i) => i.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { 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); + if (startAisle !== activeItem.aisle) { + void persistAisleChange(activeItem.id, activeItem.aisle, startAisle); + } } const filtered = useMemo(() => { @@ -374,7 +432,13 @@ export function ShoppingListView({ {filtered.length === 0 ? (

{t("noSearchResults")}

) : showGroups ? ( - +
{Object.entries(grouped).sort(([a], [b]) => groupLabel(a).localeCompare(groupLabel(b))).map(([key, groupItems]) => (