fix: dragging an item into a different shopping-list category didn't work
Each category group had its own isolated DndContext, so dnd-kit had no way to resolve a drop that landed in a different group — cross-category drags were silently no-ops by construction, not an edge-case bug. Lifted to a single DndContext wrapping all groups, with one handleDragEnd that looks up both the dragged item's and the drop target's current group from live state: same group reorders as before, different group changes the item's aisle (appended after the item it was dropped on) via the existing changeCategory persistence path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,12 @@ type Item = {
|
|||||||
|
|
||||||
type SortMode = "category" | "alpha" | "unchecked";
|
type SortMode = "category" | "alpha" | "unchecked";
|
||||||
|
|
||||||
|
// Sentinel group key for items with no aisle (the "Other" bucket) — using a stable,
|
||||||
|
// locale-independent key (rather than the translated "Other" text) as the group
|
||||||
|
// identifier avoids that identifier changing across locales and lets it double as a
|
||||||
|
// dnd-kit droppable/group key.
|
||||||
|
const OTHER_KEY = "__other__";
|
||||||
|
|
||||||
export function ShoppingListView({
|
export function ShoppingListView({
|
||||||
listId,
|
listId,
|
||||||
initialItems,
|
initialItems,
|
||||||
@@ -81,6 +87,7 @@ export function ShoppingListView({
|
|||||||
const [movingToPantry, setMovingToPantry] = useState(false);
|
const [movingToPantry, setMovingToPantry] = useState(false);
|
||||||
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 [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);
|
||||||
@@ -232,22 +239,48 @@ export function ShoppingListView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGroupDragEnd(groupItems: Item[], event: DragEndEvent) {
|
function groupKeyOf(item: Item): string {
|
||||||
|
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) {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
const oldIndex = groupItems.findIndex((i) => i.id === active.id);
|
|
||||||
const newIndex = groupItems.findIndex((i) => i.id === over.id);
|
|
||||||
if (oldIndex === -1 || newIndex === -1) return;
|
|
||||||
const reordered = arrayMove(groupItems, oldIndex, newIndex);
|
|
||||||
const reorderedIds = new Set(reordered.map((i) => i.id));
|
|
||||||
|
|
||||||
setItems((prev) => {
|
const activeItem = items.find((i) => i.id === active.id);
|
||||||
// Splice the reordered group items back into their original positions within
|
const overItem = items.find((i) => i.id === over.id);
|
||||||
// the full list, leaving every other item untouched.
|
if (!activeItem || !overItem) return;
|
||||||
let cursor = 0;
|
|
||||||
return prev.map((i) => (reorderedIds.has(i.id) ? reordered[cursor++]! : i));
|
const sourceKey = groupKeyOf(activeItem);
|
||||||
});
|
const destKey = groupKeyOf(overItem);
|
||||||
void persistOrder(reordered);
|
|
||||||
|
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;
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
@@ -284,12 +317,16 @@ export function ShoppingListView({
|
|||||||
|
|
||||||
const grouped = showGroups
|
const grouped = showGroups
|
||||||
? filtered.reduce<Record<string, Item[]>>((acc, item) => {
|
? filtered.reduce<Record<string, Item[]>>((acc, item) => {
|
||||||
const key = item.aisle ?? t("aisleOther");
|
const key = groupKeyOf(item);
|
||||||
(acc[key] ??= []).push(item);
|
(acc[key] ??= []).push(item);
|
||||||
return acc;
|
return acc;
|
||||||
}, {})
|
}, {})
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
function groupLabel(key: string): string {
|
||||||
|
return key === OTHER_KEY ? tShopping("aisleOther") : categoryLabel(key);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -337,22 +374,25 @@ 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 ? (
|
||||||
Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<ItemGroup
|
<div className="space-y-6">
|
||||||
key={aisle}
|
{Object.entries(grouped).sort(([a], [b]) => groupLabel(a).localeCompare(groupLabel(b))).map(([key, groupItems]) => (
|
||||||
aisleLabel={categoryLabel(aisle)}
|
<ItemGroup
|
||||||
items={aisleItems}
|
key={key}
|
||||||
showHeader={Object.keys(grouped).length > 1}
|
aisleLabel={groupLabel(key)}
|
||||||
readOnly={readOnly}
|
items={groupItems}
|
||||||
categoryOptions={categoryOptions}
|
showHeader={Object.keys(grouped).length > 1}
|
||||||
categoryLabel={categoryLabel}
|
readOnly={readOnly}
|
||||||
tShopping={tShopping}
|
categoryOptions={categoryOptions}
|
||||||
onToggle={toggleItem}
|
categoryLabel={categoryLabel}
|
||||||
onChangeCategory={changeCategory}
|
tShopping={tShopping}
|
||||||
onDeleteRequest={setDeleteTarget}
|
onToggle={toggleItem}
|
||||||
onDragEnd={(event) => handleGroupDragEnd(aisleItems, event)}
|
onChangeCategory={changeCategory}
|
||||||
/>
|
onDeleteRequest={setDeleteTarget}
|
||||||
))
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
) : (
|
) : (
|
||||||
<FlatItemList
|
<FlatItemList
|
||||||
items={flatItems}
|
items={flatItems}
|
||||||
@@ -400,7 +440,6 @@ function ItemGroup({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onChangeCategory,
|
onChangeCategory,
|
||||||
onDeleteRequest,
|
onDeleteRequest,
|
||||||
onDragEnd,
|
|
||||||
}: {
|
}: {
|
||||||
aisleLabel: string;
|
aisleLabel: string;
|
||||||
items: Item[];
|
items: Item[];
|
||||||
@@ -412,34 +451,29 @@ function ItemGroup({
|
|||||||
onToggle: (item: Item) => void;
|
onToggle: (item: Item) => void;
|
||||||
onChangeCategory: (item: Item, category: string | null) => void;
|
onChangeCategory: (item: Item, category: string | null) => void;
|
||||||
onDeleteRequest: (item: Item) => void;
|
onDeleteRequest: (item: Item) => void;
|
||||||
onDragEnd: (event: DragEndEvent) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{showHeader && (
|
{showHeader && (
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisleLabel}</h2>
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisleLabel}</h2>
|
||||||
)}
|
)}
|
||||||
<div className="rounded-xl border divide-y">
|
<div className="rounded-xl border divide-y">
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||||
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
{items.map((item) => (
|
||||||
{items.map((item) => (
|
<SortableItemRow
|
||||||
<SortableItemRow
|
key={item.id}
|
||||||
key={item.id}
|
item={item}
|
||||||
item={item}
|
readOnly={readOnly}
|
||||||
readOnly={readOnly}
|
draggable={!readOnly}
|
||||||
draggable={!readOnly}
|
categoryOptions={categoryOptions}
|
||||||
categoryOptions={categoryOptions}
|
categoryLabel={categoryLabel}
|
||||||
categoryLabel={categoryLabel}
|
tShopping={tShopping}
|
||||||
tShopping={tShopping}
|
onToggle={onToggle}
|
||||||
onToggle={onToggle}
|
onChangeCategory={onChangeCategory}
|
||||||
onChangeCategory={onChangeCategory}
|
onDeleteRequest={onDeleteRequest}
|
||||||
onDeleteRequest={onDeleteRequest}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
</SortableContext>
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user