From d78dca7b1a866b76ad5d042d9108697c26332c5d Mon Sep 17 00:00:00 2001 From: Nico Date: Thu, 4 Jun 2026 05:19:49 -0700 Subject: [PATCH] fix: guard unsaved map app navigation --- frontend/src/pages/LocationMapManager.jsx | 62 ++++++++++++++++++--- frontend/tests/location-map-manager.spec.ts | 49 ++++++++++++++++ 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx index a4b8fdc..0535f32 100644 --- a/frontend/src/pages/LocationMapManager.jsx +++ b/frontend/src/pages/LocationMapManager.jsx @@ -73,7 +73,7 @@ export default function LocationMapManager() { const [future, setFuture] = useState([]); const [dragState, setDragState] = useState(null); const [pendingDeleteObject, setPendingDeleteObject] = useState(null); - const [pendingLeave, setPendingLeave] = useState(false); + const [pendingLeaveTarget, setPendingLeaveTarget] = useState(null); const svgRef = useRef(null); const toastRef = useRef(toast); const loadRequestRef = useRef(0); @@ -141,6 +141,45 @@ export default function LocationMapManager() { { capture: true } ); + useEffect(() => { + if (!shouldGuardLeave) { + setPendingLeaveTarget(null); + return undefined; + } + + const handleDocumentClick = (event) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) { + return; + } + + const target = event.target instanceof Element ? event.target : null; + const anchor = target?.closest("a[href]"); + if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) { + return; + } + + const nextUrl = new URL(anchor.href, window.location.href); + if (nextUrl.origin !== window.location.origin) return; + + const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; + const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`; + if (nextPath === currentPath) return; + + event.preventDefault(); + setPendingLeaveTarget({ type: "path", path: nextPath }); + }; + + document.addEventListener("click", handleDocumentClick, true); + return () => document.removeEventListener("click", handleDocumentClick, true); + }, [shouldGuardLeave]); + const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft) => { const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft); const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft); @@ -562,12 +601,22 @@ export default function LocationMapManager() { const handleBack = useCallback(() => { if (shouldGuardLeave) { - setPendingLeave(true); + setPendingLeaveTarget({ type: "back" }); return; } performBackNavigation(); }, [performBackNavigation, shouldGuardLeave]); + const confirmPendingLeave = useCallback(() => { + const target = pendingLeaveTarget; + setPendingLeaveTarget(null); + if (target?.type === "path" && target.path) { + navigate(target.path); + return; + } + performBackNavigation(); + }, [navigate, pendingLeaveTarget, performBackNavigation]); + if (!hasLoaded || householdLoading || loading) { return (
@@ -721,15 +770,12 @@ export default function LocationMapManager() { onConfirm={handleDeleteObject} /> setPendingLeave(false)} - onConfirm={() => { - setPendingLeave(false); - performBackNavigation(); - }} + onClose={() => setPendingLeaveTarget(null)} + onConfirm={confirmPendingLeave} />
); diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index 06ae0a5..3177fce 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -1453,6 +1453,55 @@ test("admin hard navigation warns when draft edits are unsaved", async ({ page } }); }); +test("admin app navigation warns when draft edits are unsaved", async ({ page }) => { + await mockMapShell(page); + + const mapState = draftMapState([ + { + id: 1372, + 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 Areas" }).click(); + await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); + await page.getByRole("textbox", { name: "Label" }).fill("Unsaved Navbar Bakery"); + + await page.getByRole("link", { name: "Home" }).click(); + + await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible(); + await page.getByRole("button", { name: "Cancel" }).click(); + await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/); + await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Unsaved Navbar Bakery"); + + await page.getByRole("link", { name: "Home" }).click(); + await confirmSlide(page); + await expect(page).toHaveURL(/\/$/); +}); + test("admin publish saves pending map edits before publishing", async ({ page }) => { await mockMapShell(page);