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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user