Add drag reorder for zones #18

Merged
nalalangan merged 1 commits from feature/drag-reorder-zones into feature-custom-store-locations 2026-05-31 19:42:58 -09:00
3 changed files with 195 additions and 21 deletions
Showing only changes of commit 5e2b2e6e5a - Show all commits

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
createLocationZone, createLocationZone,
deleteLocationZone, deleteLocationZone,
@ -102,9 +102,15 @@ export default function StoreZoneManager({
const [pendingDeleteZones, setPendingDeleteZones] = useState([]); const [pendingDeleteZones, setPendingDeleteZones] = useState([]);
const [editingZone, setEditingZone] = useState(null); const [editingZone, setEditingZone] = useState(null);
const [editingZoneDraft, setEditingZoneDraft] = useState({ name: "" }); const [editingZoneDraft, setEditingZoneDraft] = useState({ name: "" });
const [draggedZoneId, setDraggedZoneId] = useState(null);
const [dragOverZoneId, setDragOverZoneId] = useState(null);
const [reordering, setReordering] = useState(false);
const draggedZoneIdRef = useRef(null);
const hasDraggedZoneRef = useRef(false);
const selectedDeleteZones = zones.filter((zone) => selectedDeleteIds.has(zone.id)); const selectedDeleteZones = zones.filter((zone) => selectedDeleteIds.has(zone.id));
const selectedDeleteCount = selectedDeleteZones.length; const selectedDeleteCount = selectedDeleteZones.length;
const canDragReorder = canManage && !deleteMode && !loading && !reordering;
const loadZones = async () => { const loadZones = async () => {
if (!householdId || !location?.id) return; if (!householdId || !location?.id) return;
@ -128,6 +134,10 @@ export default function StoreZoneManager({
setSelectedDeleteIds(new Set()); setSelectedDeleteIds(new Set());
setPendingDeleteZones([]); setPendingDeleteZones([]);
setEditingZone(null); setEditingZone(null);
setDraggedZoneId(null);
setDragOverZoneId(null);
draggedZoneIdRef.current = null;
hasDraggedZoneRef.current = false;
}; };
const openZoneSettings = (zone) => { const openZoneSettings = (zone) => {
@ -194,30 +204,95 @@ export default function StoreZoneManager({
} }
}; };
const handleMoveZone = async (zone, direction) => { const persistZoneOrder = async (orderedZones) => {
const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id); const zonesWithSortOrder = orderedZones.map((zone, index) => ({
const swapIndex = currentIndex + direction; ...zone,
if (currentIndex < 0 || swapIndex < 0 || swapIndex >= zones.length) return; sort_order: (index + 1) * 10,
}));
const changedZones = zonesWithSortOrder.filter((zone) => {
const currentZone = zones.find((candidate) => candidate.id === zone.id);
return currentZone && currentZone.sort_order !== zone.sort_order;
});
const other = zones[swapIndex]; if (changedZones.length === 0) return true;
setReordering(true);
setZones(zonesWithSortOrder);
try { try {
await Promise.all([ await Promise.all(
updateLocationZone(householdId, location.id, zone.id, { changedZones.map((zone) =>
sort_order: other.sort_order, updateLocationZone(householdId, location.id, zone.id, {
}), sort_order: zone.sort_order,
updateLocationZone(householdId, location.id, other.id, { })
sort_order: zone.sort_order, )
}), );
]);
await loadZones(); await loadZones();
await refreshActiveZones(); await refreshActiveZones();
setEditingZone(null); toast.success("Reordered zones", "Updated shopping order");
return true;
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to reorder zones"); const message = getApiErrorMessage(error, "Failed to reorder zones");
toast.error("Reorder zones failed", `Reorder zones failed: ${message}`); toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
await loadZones();
return false;
} finally {
setReordering(false);
} }
}; };
const moveZone = async (fromIndex, toIndex) => {
if (fromIndex < 0 || toIndex < 0 || fromIndex >= zones.length || toIndex >= zones.length) {
return false;
}
const orderedZones = [...zones];
const [movedZone] = orderedZones.splice(fromIndex, 1);
orderedZones.splice(toIndex, 0, movedZone);
return persistZoneOrder(orderedZones);
};
const handleMoveZone = async (zone, direction) => {
const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
const moved = await moveZone(currentIndex, currentIndex + direction);
if (moved) {
setEditingZone(null);
}
};
const handleZoneDragStart = (event, zoneId) => {
if (!canDragReorder) {
event.preventDefault();
return;
}
draggedZoneIdRef.current = zoneId;
hasDraggedZoneRef.current = true;
setDraggedZoneId(zoneId);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(zoneId));
};
const clearZoneDrag = () => {
draggedZoneIdRef.current = null;
setDraggedZoneId(null);
setDragOverZoneId(null);
window.setTimeout(() => {
hasDraggedZoneRef.current = false;
}, 0);
};
const handleZoneDrop = async (event, targetZoneId) => {
event.preventDefault();
const sourceZoneId = draggedZoneIdRef.current || event.dataTransfer.getData("text/plain");
clearZoneDrag();
if (!sourceZoneId || String(sourceZoneId) === String(targetZoneId)) return;
const sourceIndex = zones.findIndex((zone) => String(zone.id) === String(sourceZoneId));
const targetIndex = zones.findIndex((zone) => String(zone.id) === String(targetZoneId));
await moveZone(sourceIndex, targetIndex);
};
const handleSaveZone = async () => { const handleSaveZone = async () => {
if (!editingZone) return; if (!editingZone) return;
@ -341,14 +416,27 @@ export default function StoreZoneManager({
<button <button
key={zone.id} key={zone.id}
type="button" type="button"
className={`store-items-table-row store-items-table-row-button store-management-row store-management-row-with-order ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`} className={`store-items-table-row store-items-table-row-button store-management-row store-management-row-with-order ${canDragReorder ? "store-management-row-with-drag" : ""} ${draggedZoneId === zone.id ? "is-dragging" : ""} ${dragOverZoneId === zone.id ? "is-drag-target" : ""} ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
aria-label={ aria-label={
deleteMode deleteMode
? `${isSelectedForDelete ? "Deselect" : "Select"} ${zone.name} for deletion` ? `${isSelectedForDelete ? "Deselect" : "Select"} ${zone.name} for deletion`
: `Edit zone ${zone.name}` : `Edit zone ${zone.name}`
} }
aria-pressed={deleteMode ? isSelectedForDelete : undefined} aria-pressed={deleteMode ? isSelectedForDelete : undefined}
onDragOver={(event) => {
if (!canDragReorder || !draggedZoneIdRef.current) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDragOverZoneId(zone.id);
}}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setDragOverZoneId(null);
}
}}
onDrop={(event) => handleZoneDrop(event, zone.id)}
onClick={() => { onClick={() => {
if (hasDraggedZoneRef.current) return;
if (deleteMode) { if (deleteMode) {
toggleZoneSelection(zone.id); toggleZoneSelection(zone.id);
} else { } else {
@ -356,6 +444,17 @@ export default function StoreZoneManager({
} }
}} }}
> >
{canDragReorder ? (
<span
className="store-zone-drag-handle"
draggable
aria-hidden="true"
title="Drag to reorder"
onClick={(event) => event.stopPropagation()}
onDragStart={(event) => handleZoneDragStart(event, zone.id)}
onDragEnd={clearZoneDrag}
/>
) : null}
<span className="store-management-order">{index + 1}</span> <span className="store-management-order">{index + 1}</span>
<span className="store-management-name">{zone.name}</span> <span className="store-management-name">{zone.name}</span>
{deleteMode ? ( {deleteMode ? (

View File

@ -175,9 +175,17 @@
} }
.store-management-row-with-order { .store-management-row-with-order {
grid-template-columns: auto minmax(0, 1fr);
}
.store-management-row-with-order.is-delete-selectable {
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
} }
.store-management-row-with-drag {
grid-template-columns: auto auto minmax(0, 1fr);
}
.store-management-order { .store-management-order {
width: 2rem; width: 2rem;
color: var(--text-secondary); color: var(--text-secondary);
@ -194,6 +202,36 @@
white-space: nowrap; white-space: nowrap;
} }
.store-zone-drag-handle {
width: 2rem;
height: 2rem;
display: inline-flex;
border-radius: var(--border-radius-sm);
color: var(--text-secondary);
cursor: grab;
touch-action: none;
background-image: radial-gradient(currentColor 1.4px, transparent 1.4px);
background-position: center;
background-size: 6px 6px;
background-repeat: repeat;
}
.store-zone-drag-handle:hover,
.store-zone-drag-handle:focus-visible {
color: var(--color-primary);
background-color: var(--color-primary-light);
outline: none;
}
.store-management-row.is-dragging {
opacity: 0.55;
}
.store-management-row.is-drag-target {
border-color: var(--color-primary);
box-shadow: inset 3px 0 0 var(--color-primary);
}
.store-management-meta { .store-management-meta {
grid-column: 1 / -1; grid-column: 1 / -1;
min-width: 0; min-width: 0;
@ -292,6 +330,18 @@
text-align: left; text-align: left;
} }
.store-management-row-with-order {
grid-template-columns: auto minmax(0, 1fr);
}
.store-management-row-with-order.is-delete-selectable {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.store-management-row-with-drag {
grid-template-columns: auto auto minmax(0, 1fr);
}
.store-settings-actions { .store-settings-actions {
justify-content: stretch; justify-content: stretch;
} }

View File

@ -192,6 +192,7 @@ test("manage stores opens a modal to edit and delete household store items", asy
}); });
test("manage stores uses modal flows for locations and zones", async ({ page }) => { test("manage stores uses modal flows for locations and zones", async ({ page }) => {
await page.setViewportSize({ width: 460, height: 1000 });
await seedAuthStorage(page, { username: "store-manager", role: "owner" }); await seedAuthStorage(page, { username: "store-manager", role: "owner" });
await mockConfig(page); await mockConfig(page);
await mockHouseholdAndStoreShell(page, { await mockHouseholdAndStoreShell(page, {
@ -327,7 +328,9 @@ test("manage stores uses modal flows for locations and zones", async ({ page })
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ zones }), body: JSON.stringify({
zones: [...zones].sort((a, b) => a.sort_order - b.sort_order),
}),
}); });
return; return;
} }
@ -349,25 +352,27 @@ test("manage stores uses modal flows for locations and zones", async ({ page })
await route.fulfill({ status: 405 }); await route.fulfill({ status: 405 });
}); });
await page.route("**/households/1/locations/10/zones/901", async (route) => { await page.route("**/households/1/locations/10/zones/*", async (route) => {
const request = route.request(); const request = route.request();
const zoneId = Number(new URL(request.url()).pathname.split("/").pop());
if (request.method() === "PATCH") { if (request.method() === "PATCH") {
const body = request.postDataJSON() as { name?: string; sort_order?: number }; const body = request.postDataJSON() as { name?: string; sort_order?: number };
zones = zones.map((zone) => zones = zones.map((zone) =>
zone.id === 901 zone.id === zoneId
? { ...zone, name: body.name || zone.name, sort_order: body.sort_order ?? zone.sort_order } ? { ...zone, name: body.name || zone.name, sort_order: body.sort_order ?? zone.sort_order }
: zone : zone
); );
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ zone: zones.find((zone) => zone.id === 901) }), body: JSON.stringify({ zone: zones.find((zone) => zone.id === zoneId) }),
}); });
return; return;
} }
if (request.method() === "DELETE") { if (request.method() === "DELETE") {
zones = zones.filter((zone) => zone.id !== 901); zones = zones.filter((zone) => zone.id !== zoneId);
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@ -429,6 +434,26 @@ test("manage stores uses modal flows for locations and zones", async ({ page })
await expect(zonesModal.getByRole("button", { name: "Down" })).toHaveCount(0); await expect(zonesModal.getByRole("button", { name: "Down" })).toHaveCount(0);
await expect(zonesModal.getByRole("button", { name: "Remove" })).toHaveCount(0); await expect(zonesModal.getByRole("button", { name: "Remove" })).toHaveCount(0);
const bakeryZoneRow = zonesModal.getByRole("button", { name: "Edit zone Bakery" });
const frozenZoneRow = zonesModal.getByRole("button", { name: "Edit zone Frozen Foods" });
await expect(bakeryZoneRow.locator(".store-zone-drag-handle")).toBeVisible();
const bakeryOrderBox = await bakeryZoneRow.locator(".store-management-order").boundingBox();
const bakeryNameBox = await bakeryZoneRow.locator(".store-management-name").boundingBox();
expect(bakeryOrderBox).not.toBeNull();
expect(bakeryNameBox).not.toBeNull();
expect(
Math.abs(
((bakeryOrderBox?.y ?? 0) + (bakeryOrderBox?.height ?? 0) / 2) -
((bakeryNameBox?.y ?? 0) + (bakeryNameBox?.height ?? 0) / 2)
)
).toBeLessThan(3);
await bakeryZoneRow.locator(".store-zone-drag-handle").dragTo(frozenZoneRow);
await expect(
page.locator(".action-toast.action-toast-success").filter({ hasText: "Reordered zones" })
).toContainText("Reordered zones");
await expect(zonesModal.locator(".store-management-row-with-order").first()).toContainText("Frozen Foods");
await zonesModal.getByRole("button", { name: "Edit zone Bakery" }).click(); await zonesModal.getByRole("button", { name: "Edit zone Bakery" }).click();
const zoneSettings = page.locator(".store-items-modal").filter({ const zoneSettings = page.locator(".store-items-modal").filter({
has: page.getByRole("heading", { name: "Bakery Settings" }), has: page.getByRole("heading", { name: "Bakery Settings" }),