From 1eaddd8a07f4ff82a3619de8b18ae02b4e428516 Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 3 Jun 2026 13:49:33 -0700 Subject: [PATCH] fix: avoid dirty map state on selection --- .../src/components/maps/LocationMapCanvas.jsx | 15 ++++- frontend/tests/location-map-manager.spec.ts | 57 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/maps/LocationMapCanvas.jsx b/frontend/src/components/maps/LocationMapCanvas.jsx index 119ce11..a20f088 100644 --- a/frontend/src/components/maps/LocationMapCanvas.jsx +++ b/frontend/src/components/maps/LocationMapCanvas.jsx @@ -5,6 +5,8 @@ import { itemsForZone, } from "../../lib/locationMapUtils"; +const DRAG_CHANGE_THRESHOLD = 2; + export default function LocationMapCanvas({ mode, editorTool, @@ -26,6 +28,7 @@ export default function LocationMapCanvas({ const canDragPan = mode === "view" || editorTool === "pan"; const scrollRef = useRef(null); const panDragRef = useRef(null); + const objectDragHistoryCapturedRef = useRef(false); const suppressPanClickRef = useRef(false); const [isPanning, setIsPanning] = useState(false); @@ -33,7 +36,7 @@ export default function LocationMapCanvas({ if (!canEditObjects || object.locked || !svgRef.current) return; event.stopPropagation(); event.preventDefault(); - remember(); + objectDragHistoryCapturedRef.current = false; const point = clientPointToMap(event, svgRef.current, mapSize); event.currentTarget.setPointerCapture?.(event.pointerId); setSelectedObjectKey(getObjectKey(object)); @@ -51,6 +54,15 @@ export default function LocationMapCanvas({ const point = clientPointToMap(event, svgRef.current, mapSize); const deltaX = point.x - dragState.startPoint.x; const deltaY = point.y - dragState.startPoint.y; + const hasMeaningfulChange = + Math.abs(deltaX) >= DRAG_CHANGE_THRESHOLD || + Math.abs(deltaY) >= DRAG_CHANGE_THRESHOLD; + + if (!hasMeaningfulChange) return; + if (!objectDragHistoryCapturedRef.current) { + remember(); + objectDragHistoryCapturedRef.current = true; + } updateObjects((currentObjects) => currentObjects.map((object) => { @@ -72,6 +84,7 @@ export default function LocationMapCanvas({ }; const handlePointerUp = () => { + objectDragHistoryCapturedRef.current = false; setDragState(null); }; diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index 5002d86..62d4548 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -433,6 +433,63 @@ test("admin status follows the visible map when a saved draft exists", async ({ await expect(page.locator(".location-map-object", { hasText: "Live Bakery" })).toBeVisible(); }); +test("admin selecting an object does not mark a draft dirty until it changes", async ({ page }) => { + await mockMapShell(page); + + const mapState = draftMapState([ + { + id: 1351, + 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: "Edit Objects" }).click(); + const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first(); + await bakeryObject.click(); + + await expect(page.getByRole("textbox", { name: "Label" })).toBeVisible(); + await expect(page.locator(".location-map-status")).toHaveText("Draft"); + await expect(page.getByRole("button", { name: "Save Draft" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled(); + + const objectBox = await bakeryObject.boundingBox(); + expect(objectBox).not.toBeNull(); + if (!objectBox) throw new Error("Bakery map object was not measurable"); + await page.mouse.move(objectBox.x + objectBox.width / 2, objectBox.y + objectBox.height / 2); + await page.mouse.down(); + await page.mouse.move(objectBox.x + objectBox.width / 2 + 50, objectBox.y + objectBox.height / 2 + 30, { + steps: 8, + }); + await page.mouse.up(); + + await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); + await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled(); +}); + test("admin publish saves pending map edits before publishing", async ({ page }) => { await mockMapShell(page);