feat: add drag reorder for zones
This commit is contained in:
parent
d94a169417
commit
5e2b2e6e5a
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
createLocationZone,
|
||||
deleteLocationZone,
|
||||
@ -102,9 +102,15 @@ export default function StoreZoneManager({
|
||||
const [pendingDeleteZones, setPendingDeleteZones] = useState([]);
|
||||
const [editingZone, setEditingZone] = useState(null);
|
||||
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 selectedDeleteCount = selectedDeleteZones.length;
|
||||
const canDragReorder = canManage && !deleteMode && !loading && !reordering;
|
||||
|
||||
const loadZones = async () => {
|
||||
if (!householdId || !location?.id) return;
|
||||
@ -128,6 +134,10 @@ export default function StoreZoneManager({
|
||||
setSelectedDeleteIds(new Set());
|
||||
setPendingDeleteZones([]);
|
||||
setEditingZone(null);
|
||||
setDraggedZoneId(null);
|
||||
setDragOverZoneId(null);
|
||||
draggedZoneIdRef.current = null;
|
||||
hasDraggedZoneRef.current = false;
|
||||
};
|
||||
|
||||
const openZoneSettings = (zone) => {
|
||||
@ -194,30 +204,95 @@ export default function StoreZoneManager({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveZone = async (zone, direction) => {
|
||||
const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
|
||||
const swapIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || swapIndex < 0 || swapIndex >= zones.length) return;
|
||||
const persistZoneOrder = async (orderedZones) => {
|
||||
const zonesWithSortOrder = orderedZones.map((zone, index) => ({
|
||||
...zone,
|
||||
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 {
|
||||
await Promise.all([
|
||||
updateLocationZone(householdId, location.id, zone.id, {
|
||||
sort_order: other.sort_order,
|
||||
}),
|
||||
updateLocationZone(householdId, location.id, other.id, {
|
||||
sort_order: zone.sort_order,
|
||||
}),
|
||||
]);
|
||||
await Promise.all(
|
||||
changedZones.map((zone) =>
|
||||
updateLocationZone(householdId, location.id, zone.id, {
|
||||
sort_order: zone.sort_order,
|
||||
})
|
||||
)
|
||||
);
|
||||
await loadZones();
|
||||
await refreshActiveZones();
|
||||
setEditingZone(null);
|
||||
toast.success("Reordered zones", "Updated shopping order");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to reorder zones");
|
||||
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 () => {
|
||||
if (!editingZone) return;
|
||||
|
||||
@ -341,14 +416,27 @@ export default function StoreZoneManager({
|
||||
<button
|
||||
key={zone.id}
|
||||
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={
|
||||
deleteMode
|
||||
? `${isSelectedForDelete ? "Deselect" : "Select"} ${zone.name} for deletion`
|
||||
: `Edit zone ${zone.name}`
|
||||
}
|
||||
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={() => {
|
||||
if (hasDraggedZoneRef.current) return;
|
||||
if (deleteMode) {
|
||||
toggleZoneSelection(zone.id);
|
||||
} 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-name">{zone.name}</span>
|
||||
{deleteMode ? (
|
||||
|
||||
@ -175,9 +175,17 @@
|
||||
}
|
||||
|
||||
.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-management-order {
|
||||
width: 2rem;
|
||||
color: var(--text-secondary);
|
||||
@ -194,6 +202,36 @@
|
||||
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 {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
@ -292,6 +330,18 @@
|
||||
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 {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
@ -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 }) => {
|
||||
await page.setViewportSize({ width: 460, height: 1000 });
|
||||
await seedAuthStorage(page, { username: "store-manager", role: "owner" });
|
||||
await mockConfig(page);
|
||||
await mockHouseholdAndStoreShell(page, {
|
||||
@ -327,7 +328,9 @@ test("manage stores uses modal flows for locations and zones", async ({ page })
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ zones }),
|
||||
body: JSON.stringify({
|
||||
zones: [...zones].sort((a, b) => a.sort_order - b.sort_order),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -349,25 +352,27 @@ test("manage stores uses modal flows for locations and zones", async ({ page })
|
||||
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 zoneId = Number(new URL(request.url()).pathname.split("/").pop());
|
||||
|
||||
if (request.method() === "PATCH") {
|
||||
const body = request.postDataJSON() as { name?: string; sort_order?: number };
|
||||
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
|
||||
);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ zone: zones.find((zone) => zone.id === 901) }),
|
||||
body: JSON.stringify({ zone: zones.find((zone) => zone.id === zoneId) }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "DELETE") {
|
||||
zones = zones.filter((zone) => zone.id !== 901);
|
||||
zones = zones.filter((zone) => zone.id !== zoneId);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
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: "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();
|
||||
const zoneSettings = page.locator(".store-items-modal").filter({
|
||||
has: page.getByRole("heading", { name: "Bakery Settings" }),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user