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";
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<SortMode>("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<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Item | null>(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 ? (
<p className="text-muted-foreground text-sm">{t("noSearchResults")}</p>
) : showGroups ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="space-y-6">
{Object.entries(grouped).sort(([a], [b]) => groupLabel(a).localeCompare(groupLabel(b))).map(([key, groupItems]) => (
<ItemGroup