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";
|
||||
|
||||
// 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({
|
||||
listId,
|
||||
initialItems,
|
||||
@@ -81,6 +87,7 @@ export function ShoppingListView({
|
||||
const [movingToPantry, setMovingToPantry] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("category");
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||
const [deleteTarget, setDeleteTarget] = useState<Item | null>(null);
|
||||
const [deleting, setDeleting] = 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;
|
||||
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) => {
|
||||
// Splice the reordered group items back into their original positions within
|
||||
// the full list, leaving every other item untouched.
|
||||
let cursor = 0;
|
||||
return prev.map((i) => (reorderedIds.has(i.id) ? reordered[cursor++]! : i));
|
||||
});
|
||||
void persistOrder(reordered);
|
||||
const activeItem = items.find((i) => i.id === active.id);
|
||||
const overItem = items.find((i) => i.id === over.id);
|
||||
if (!activeItem || !overItem) return;
|
||||
|
||||
const sourceKey = groupKeyOf(activeItem);
|
||||
const destKey = groupKeyOf(overItem);
|
||||
|
||||
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(() => {
|
||||
@@ -284,12 +317,16 @@ export function ShoppingListView({
|
||||
|
||||
const grouped = showGroups
|
||||
? filtered.reduce<Record<string, Item[]>>((acc, item) => {
|
||||
const key = item.aisle ?? t("aisleOther");
|
||||
const key = groupKeyOf(item);
|
||||
(acc[key] ??= []).push(item);
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
|
||||
function groupLabel(key: string): string {
|
||||
return key === OTHER_KEY ? tShopping("aisleOther") : categoryLabel(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -337,22 +374,25 @@ export function ShoppingListView({
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">{t("noSearchResults")}</p>
|
||||
) : showGroups ? (
|
||||
Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([aisle, aisleItems]) => (
|
||||
<ItemGroup
|
||||
key={aisle}
|
||||
aisleLabel={categoryLabel(aisle)}
|
||||
items={aisleItems}
|
||||
showHeader={Object.keys(grouped).length > 1}
|
||||
readOnly={readOnly}
|
||||
categoryOptions={categoryOptions}
|
||||
categoryLabel={categoryLabel}
|
||||
tShopping={tShopping}
|
||||
onToggle={toggleItem}
|
||||
onChangeCategory={changeCategory}
|
||||
onDeleteRequest={setDeleteTarget}
|
||||
onDragEnd={(event) => handleGroupDragEnd(aisleItems, event)}
|
||||
/>
|
||||
))
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<div className="space-y-6">
|
||||
{Object.entries(grouped).sort(([a], [b]) => groupLabel(a).localeCompare(groupLabel(b))).map(([key, groupItems]) => (
|
||||
<ItemGroup
|
||||
key={key}
|
||||
aisleLabel={groupLabel(key)}
|
||||
items={groupItems}
|
||||
showHeader={Object.keys(grouped).length > 1}
|
||||
readOnly={readOnly}
|
||||
categoryOptions={categoryOptions}
|
||||
categoryLabel={categoryLabel}
|
||||
tShopping={tShopping}
|
||||
onToggle={toggleItem}
|
||||
onChangeCategory={changeCategory}
|
||||
onDeleteRequest={setDeleteTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
) : (
|
||||
<FlatItemList
|
||||
items={flatItems}
|
||||
@@ -400,7 +440,6 @@ function ItemGroup({
|
||||
onToggle,
|
||||
onChangeCategory,
|
||||
onDeleteRequest,
|
||||
onDragEnd,
|
||||
}: {
|
||||
aisleLabel: string;
|
||||
items: Item[];
|
||||
@@ -412,34 +451,29 @@ function ItemGroup({
|
||||
onToggle: (item: Item) => void;
|
||||
onChangeCategory: (item: Item, category: string | null) => void;
|
||||
onDeleteRequest: (item: Item) => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
}) {
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{showHeader && (
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{aisleLabel}</h2>
|
||||
)}
|
||||
<div className="rounded-xl border divide-y">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item) => (
|
||||
<SortableItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
readOnly={readOnly}
|
||||
draggable={!readOnly}
|
||||
categoryOptions={categoryOptions}
|
||||
categoryLabel={categoryLabel}
|
||||
tShopping={tShopping}
|
||||
onToggle={onToggle}
|
||||
onChangeCategory={onChangeCategory}
|
||||
onDeleteRequest={onDeleteRequest}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item) => (
|
||||
<SortableItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
readOnly={readOnly}
|
||||
draggable={!readOnly}
|
||||
categoryOptions={categoryOptions}
|
||||
categoryLabel={categoryLabel}
|
||||
tShopping={tShopping}
|
||||
onToggle={onToggle}
|
||||
onChangeCategory={onChangeCategory}
|
||||
onDeleteRequest={onDeleteRequest}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user