diff --git a/frontend/src/components/maps/LocationMapCanvas.jsx b/frontend/src/components/maps/LocationMapCanvas.jsx index 62ee76b..fd4599f 100644 --- a/frontend/src/components/maps/LocationMapCanvas.jsx +++ b/frontend/src/components/maps/LocationMapCanvas.jsx @@ -229,7 +229,7 @@ export default function LocationMapCanvas({ if (!hasObjectBoundsChanged(draggedObject, nextDraggedObject)) return; if (!objectDragHistoryCapturedRef.current) { - remember(); + remember(undefined, dragState.key); objectDragHistoryCapturedRef.current = true; } @@ -292,7 +292,7 @@ export default function LocationMapCanvas({ setSelectedObjectKey(getObjectKey(object)); if (!hasObjectBoundsChanged(object, nextObject)) return; - remember(); + remember(undefined, getObjectKey(object)); updateObjects((currentObjects) => currentObjects.map((candidate) => getObjectKey(candidate) === getObjectKey(object) diff --git a/frontend/src/hooks/useLocationMapObjectActions.js b/frontend/src/hooks/useLocationMapObjectActions.js index ca687c2..e66be8d 100644 --- a/frontend/src/hooks/useLocationMapObjectActions.js +++ b/frontend/src/hooks/useLocationMapObjectActions.js @@ -9,6 +9,13 @@ import { const EMPTY_ZONES = []; +function createHistorySnapshot(objects, selectedObjectKey) { + return { + objects: objects.map((object) => ({ ...object })), + selectedObjectKey: selectedObjectKey ?? null, + }; +} + export default function useLocationMapObjectActions({ canManage, hiddenAreaCount, @@ -27,10 +34,13 @@ export default function useLocationMapObjectActions({ const [pendingDeleteObject, setPendingDeleteObject] = useState(null); const zones = mapState?.zones || EMPTY_ZONES; - const remember = useCallback((currentObjects = objects) => { - setHistory((previous) => [...previous.slice(-19), currentObjects.map((object) => ({ ...object }))]); + const remember = useCallback((currentObjects = objects, currentSelectedObjectKey = selectedObjectKey) => { + setHistory((previous) => [ + ...previous.slice(-19), + createHistorySnapshot(currentObjects, currentSelectedObjectKey), + ]); setFuture([]); - }, [objects, setFuture, setHistory]); + }, [objects, selectedObjectKey, setFuture, setHistory]); const updateObjects = useCallback((updater) => { setHasUnsavedChanges(true); diff --git a/frontend/src/hooks/useLocationMapViewControls.js b/frontend/src/hooks/useLocationMapViewControls.js index 8e079fa..f15f06c 100644 --- a/frontend/src/hooks/useLocationMapViewControls.js +++ b/frontend/src/hooks/useLocationMapViewControls.js @@ -2,6 +2,31 @@ import { useCallback, useEffect } from "react"; import useMobileMapAutoFit from "./useMobileMapAutoFit"; import { getObjectKey } from "../lib/locationMapUtils"; +function cloneMapObjects(objects = []) { + return objects.map((object) => ({ ...object })); +} + +function getHistorySnapshot(snapshot, fallbackSelectedObjectKey = null) { + if (Array.isArray(snapshot)) { + return { + objects: cloneMapObjects(snapshot), + selectedObjectKey: fallbackSelectedObjectKey, + }; + } + + return { + objects: cloneMapObjects(snapshot?.objects || []), + selectedObjectKey: snapshot?.selectedObjectKey ?? null, + }; +} + +function createHistorySnapshot(objects, selectedObjectKey) { + return { + objects: cloneMapObjects(objects), + selectedObjectKey: selectedObjectKey ?? null, + }; +} + export default function useLocationMapViewControls({ filters, hasUnsavedChanges, @@ -45,24 +70,38 @@ export default function useLocationMapViewControls({ const handleUndo = useCallback(() => { setHistory((previous) => { if (previous.length === 0) return previous; - const priorObjects = previous[previous.length - 1]; - setFuture((nextFuture) => [objects.map((object) => ({ ...object })), ...nextFuture.slice(0, 19)]); - setObjects(priorObjects); + const priorSnapshot = getHistorySnapshot(previous[previous.length - 1], selectedObjectKey); + setFuture((nextFuture) => [ + createHistorySnapshot(objects, selectedObjectKey), + ...nextFuture.slice(0, 19), + ]); + setObjects(priorSnapshot.objects); + setSelectedObjectKey(priorSnapshot.selectedObjectKey); setHasUnsavedChanges(true); return previous.slice(0, -1); }); - }, [objects, setFuture, setHasUnsavedChanges, setHistory, setObjects]); + }, [ + objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory, + setObjects, setSelectedObjectKey, + ]); const handleRedo = useCallback(() => { setFuture((previous) => { if (previous.length === 0) return previous; - const nextObjects = previous[0]; - setHistory((nextHistory) => [...nextHistory.slice(-19), objects.map((object) => ({ ...object }))]); - setObjects(nextObjects); + const nextSnapshot = getHistorySnapshot(previous[0], selectedObjectKey); + setHistory((nextHistory) => [ + ...nextHistory.slice(-19), + createHistorySnapshot(objects, selectedObjectKey), + ]); + setObjects(nextSnapshot.objects); + setSelectedObjectKey(nextSnapshot.selectedObjectKey); setHasUnsavedChanges(true); return previous.slice(1); }); - }, [objects, setFuture, setHasUnsavedChanges, setHistory, setObjects]); + }, [ + objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory, + setObjects, setSelectedObjectKey, + ]); const handlePreviewDraft = useCallback(() => { setMode("view"); diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index b3402bf..d3efe37 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -2094,6 +2094,57 @@ test("admin cleared map area labels keep linked zone action names", async ({ pag await expect(page.getByRole("button", { name: "Map area Area Copy" })).toHaveCount(0); }); +test("admin restores selected map area through duplicate undo and redo", async ({ page }) => { + await mockMapShell(page); + + const mapState = draftMapState([ + { + id: 1355, + location_map_id: 900, + zone_id: 501, + zone_name: "Bakery", + type: "zone", + label: "Bakery", + x: 40, + y: 40, + width: 260, + height: 160, + rotation: 0, + locked: false, + visible: true, + sort_order: 1, + }, + ], true); + + await page.route("**/households/1/locations/10/map", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mapState), + }); + }); + + await page.goto("/stores/100/locations/10/map"); + + await page.getByRole("button", { name: "Continue Editing" }).click(); + await page.getByRole("button", { name: "Map area Bakery" }).click(); + await expect(page.locator(".location-map-object.is-selected")).toHaveAttribute("data-object-key", "1355"); + + await page.getByRole("button", { name: "Duplicate" }).click(); + await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery Copy"); + await expect(page.locator(".location-map-object.is-selected")).toContainText("Bakery Copy"); + + await page.getByRole("button", { name: "Undo" }).click(); + await expect(page.getByRole("button", { name: "Map area Bakery Copy" })).toHaveCount(0); + await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery"); + await expect(page.locator(".location-map-object.is-selected")).toHaveAttribute("data-object-key", "1355"); + + await page.getByRole("button", { name: "Redo" }).click(); + await expect(page.getByRole("button", { name: "Map area Bakery Copy" })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery Copy"); + await expect(page.locator(".location-map-object.is-selected")).toContainText("Bakery Copy"); +}); + test("admin nudges selected map areas with arrow keys", async ({ page }) => { await mockMapShell(page);