Add drag reorder for zones #18
@ -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(
|
||||||
|
changedZones.map((zone) =>
|
||||||
updateLocationZone(householdId, location.id, zone.id, {
|
updateLocationZone(householdId, location.id, zone.id, {
|
||||||
sort_order: other.sort_order,
|
|
||||||
}),
|
|
||||||
updateLocationZone(householdId, location.id, other.id, {
|
|
||||||
sort_order: zone.sort_order,
|
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 ? (
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" }),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user