fix: dragged item didn't visually move into another category until drop

Cross-category drops worked (previous fix) but had no live preview — the
item stayed rendered in its original group the whole time you were
dragging over a different one, since dnd-kit only computes a transform
for items actually present in a group's array.

Added the standard multi-container dnd-kit pattern: onDragOver moves the
item into the target group locally as soon as you drag over it (pure
preview, nothing persisted yet); onDragEnd finalizes the exact position
and only fires a network request for whatever actually changed — order
within the final group, and the aisle itself only if it differs from
what it was when the drag started. Also handles dropping outside any
group: the local preview move is reverted instead of silently leaving an
unpersisted, visually-wrong position until refresh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-10 11:19:43 +02:00
parent d951587d31
commit 70ad1bec9c
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Check, Package, Loader2, GripVertical, MoreVertical, Trash2, Search, Sparkles } from "lucide-react"; 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 { import {
DndContext, DndContext,
DragEndEvent, DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors, useSensors,
@@ -88,6 +90,10 @@ export function ShoppingListView({
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [sortMode, setSortMode] = useState<SortMode>("category"); const [sortMode, setSortMode] = useState<SortMode>("category");
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); 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<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Item | null>(null); const [deleteTarget, setDeleteTarget] = useState<Item | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [autoCategorizing, setAutoCategorizing] = useState(false); const [autoCategorizing, setAutoCategorizing] = useState(false);
@@ -243,44 +249,96 @@ export function ShoppingListView({
return item.aisle ?? OTHER_KEY; return item.aisle ?? OTHER_KEY;
} }
// Single handler for the whole list (not per-group) — a per-group DndContext can // Persists just the aisle for one item, without touching local state — used at drag
// only ever resolve drops within its own group, which is exactly why cross-category // end once the item has already visually settled into its destination group via
// dragging didn't work before. Determines both the dragged item's and the drop // onDragOver. Reverts on failure (only the field, not its position — a rare-case
// target's group from the live `items` state, so it naturally supports moving // rollback, not worth re-deriving the exact prior array position for).
// an item into a different category, not just reordering within one. async function persistAisleChange(itemId: string, nextAisle: string | null, prevAisle: string | null) {
function handleDragEnd(event: DragEndEvent) { 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; const { active, over } = event;
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return;
const activeItem = items.find((i) => i.id === active.id); const activeItem = items.find((i) => i.id === active.id);
const overItem = items.find((i) => i.id === over.id); const overItem = items.find((i) => i.id === over.id);
if (!activeItem || !overItem) return; if (!activeItem || !overItem) return;
if (groupKeyOf(activeItem) === groupKeyOf(overItem)) return;
const sourceKey = groupKeyOf(activeItem); setItems((prev) => {
const destKey = groupKeyOf(overItem); 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) { // Single handler for the whole list (not per-group) — a per-group DndContext can
// Reordering within the same category — same as before. // only ever resolve drops within its own group, which is exactly why cross-category
const groupItems = items.filter((i) => groupKeyOf(i) === sourceKey); // dragging didn't work before. By the time this fires, onDragOver has already moved
const oldIndex = groupItems.findIndex((i) => i.id === active.id); // the item into its destination group locally, so this just finalizes the exact
const newIndex = groupItems.findIndex((i) => i.id === over.id); // position within that group and persists whatever actually changed: the new order,
if (oldIndex === -1 || newIndex === -1) return; // 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 reordered = arrayMove(groupItems, oldIndex, newIndex);
const reorderedIds = new Set(reordered.map((i) => i.id)); const reorderedIds = new Set(reordered.map((i) => i.id));
setItems((prev) => { setItems((prev) => {
let cursor = 0; let cursor = 0;
return prev.map((i) => (reorderedIds.has(i.id) ? reordered[cursor++]! : i)); return prev.map((i) => (reorderedIds.has(i.id) ? reordered[cursor++]! : i));
}); });
void persistOrder(reordered); void persistOrder(reordered);
return;
} }
// Dropped on an item in a DIFFERENT category — move it there (appended after the if (startAisle !== activeItem.aisle) {
// item it was dropped on). Reuses changeCategory for the persisted aisle update; void persistAisleChange(activeItem.id, activeItem.aisle, startAisle);
// 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(() => { const filtered = useMemo(() => {
@@ -374,7 +432,13 @@ export function ShoppingListView({
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("noSearchResults")}</p> <p className="text-muted-foreground text-sm">{t("noSearchResults")}</p>
) : showGroups ? ( ) : showGroups ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="space-y-6"> <div className="space-y-6">
{Object.entries(grouped).sort(([a], [b]) => groupLabel(a).localeCompare(groupLabel(b))).map(([key, groupItems]) => ( {Object.entries(grouped).sort(([a], [b]) => groupLabel(a).localeCompare(groupLabel(b))).map(([key, groupItems]) => (
<ItemGroup <ItemGroup