diff --git a/frontend/src/hooks/useUnsavedMapLeaveGuard.js b/frontend/src/hooks/useUnsavedMapLeaveGuard.js index c1bd893..46beba4 100644 --- a/frontend/src/hooks/useUnsavedMapLeaveGuard.js +++ b/frontend/src/hooks/useUnsavedMapLeaveGuard.js @@ -9,6 +9,9 @@ export default function useUnsavedMapLeaveGuard({ const [pendingLeaveTarget, setPendingLeaveTarget] = useState(null); const guardedClickTargetRef = useRef(null); const allowGuardedClickRef = useRef(false); + const confirmedLeaveRef = useRef(false); + const historyGuardActiveRef = useRef(false); + const guardedUrlRef = useRef(""); useBeforeUnload( useCallback((event) => { @@ -23,9 +26,21 @@ export default function useUnsavedMapLeaveGuard({ if (!shouldGuardLeave) { setPendingLeaveTarget(null); guardedClickTargetRef.current = null; + confirmedLeaveRef.current = false; + historyGuardActiveRef.current = false; return undefined; } + if (!historyGuardActiveRef.current) { + guardedUrlRef.current = window.location.href; + window.history.pushState( + { ...(window.history.state || {}), unsavedMapGuard: true }, + "", + guardedUrlRef.current + ); + historyGuardActiveRef.current = true; + } + const handleDocumentClick = (event) => { if (allowGuardedClickRef.current) return; @@ -71,9 +86,23 @@ export default function useUnsavedMapLeaveGuard({ event.preventDefault(); setPendingLeaveTarget({ type: "path", path: nextPath }); }; + const handleBrowserHistoryNavigation = () => { + if (confirmedLeaveRef.current) return; + + window.history.pushState( + { ...(window.history.state || {}), unsavedMapGuard: true }, + "", + guardedUrlRef.current || window.location.href + ); + setPendingLeaveTarget({ type: "history" }); + }; document.addEventListener("click", handleDocumentClick, true); - return () => document.removeEventListener("click", handleDocumentClick, true); + window.addEventListener("popstate", handleBrowserHistoryNavigation, true); + return () => { + document.removeEventListener("click", handleDocumentClick, true); + window.removeEventListener("popstate", handleBrowserHistoryNavigation, true); + }; }, [shouldGuardLeave]); const requestBackNavigation = useCallback(() => { @@ -92,6 +121,12 @@ export default function useUnsavedMapLeaveGuard({ const confirmPendingLeave = useCallback(() => { const target = pendingLeaveTarget; setPendingLeaveTarget(null); + confirmedLeaveRef.current = true; + if (target?.type === "history") { + historyGuardActiveRef.current = false; + window.history.go(-2); + return; + } if (target?.type === "click") { const guardedClickTarget = guardedClickTargetRef.current; guardedClickTargetRef.current = null; diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index d3efe37..39ceffe 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -2618,6 +2618,59 @@ test("admin app navigation warns when draft edits are unsaved", async ({ page }) await expect(page).toHaveURL(/\/$/); }); +test("admin browser back warns when draft edits are unsaved", async ({ page }) => { + await mockMapShell(page); + + const mapState = draftMapState([ + { + id: 1375, + 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.evaluate(() => { + window.history.replaceState(window.history.state, "", "/"); + window.history.pushState({}, "", "/stores/100/locations/10/map"); + }); + await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/); + + await page.getByRole("button", { name: "Continue Editing" }).click(); + await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); + await page.getByRole("textbox", { name: "Label" }).fill("Unsaved Back Bakery"); + + await page.evaluate(() => window.history.back()); + await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible(); + await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/); + await page.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Unsaved Back Bakery"); + + await page.evaluate(() => window.history.back()); + await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible(); + await confirmSlide(page); + await expect(page).toHaveURL(/\/$/); +}); + test("admin logout warns when draft edits are unsaved", async ({ page }) => { await mockMapShell(page);