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";
|
"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
|
||||||
|
|||||||
Reference in New Issue
Block a user