diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx index b98955e..330a1a4 100644 --- a/frontend/src/pages/LocationMapManager.jsx +++ b/frontend/src/pages/LocationMapManager.jsx @@ -1,5 +1,5 @@ import { useCallback, useContext, useEffect, useRef, useState } from "react"; -import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { useBeforeUnload, useLocation, useNavigate, useParams } from "react-router-dom"; import { createBlankLocationMap, createLocationMapFromZones, @@ -79,12 +79,22 @@ export default function LocationMapManager() { ? itemsForZone(mapState?.items || [], selectedObject.zone_id, filters, username) : []; const visibleUnmappedItems = unmappedItems(mapState?.items || [], filters, username); + const shouldGuardLeave = canManage && hasUnsavedChanges; const mapSize = { width: mapDraft.width || activeMap?.width || DEFAULT_MAP_SIZE.width, height: mapDraft.height || activeMap?.height || DEFAULT_MAP_SIZE.height, }; + useBeforeUnload( + useCallback((event) => { + if (!shouldGuardLeave) return; + event.preventDefault(); + event.returnValue = ""; + }, [shouldGuardLeave]), + { capture: true } + ); + const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft) => { const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft); const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft); @@ -435,12 +445,12 @@ export default function LocationMapManager() { }, [navigate, returnPath]); const handleBack = useCallback(() => { - if (hasUnsavedChanges && canManage) { + if (shouldGuardLeave) { setPendingLeave(true); return; } performBackNavigation(); - }, [canManage, hasUnsavedChanges, performBackNavigation]); + }, [performBackNavigation, shouldGuardLeave]); if (!hasLoaded || householdLoading || loading) { return ( diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index f060309..5d355e8 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -669,6 +669,70 @@ test("admin save progress locks draft controls", async ({ page }) => { await expect(page.getByRole("button", { name: "Save Draft" })).toBeDisabled(); }); +test("admin hard navigation warns when draft edits are unsaved", async ({ page }) => { + await mockMapShell(page); + + const mapState = draftMapState([ + { + id: 1371, + 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"); + + const cleanBeforeUnload = await page.evaluate(() => { + const event = new Event("beforeunload", { cancelable: true }); + const dispatchResult = window.dispatchEvent(event); + return { + defaultPrevented: event.defaultPrevented, + dispatchResult, + }; + }); + expect(cleanBeforeUnload).toEqual({ + defaultPrevented: false, + dispatchResult: true, + }); + + await page.getByRole("button", { name: "Continue Editing" }).click(); + await page.getByRole("button", { name: "Edit Objects" }).click(); + await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); + await page.getByRole("textbox", { name: "Label" }).fill("Hard Navigation Bakery"); + + const dirtyBeforeUnload = await page.evaluate(() => { + const event = new Event("beforeunload", { cancelable: true }); + const dispatchResult = window.dispatchEvent(event); + return { + defaultPrevented: event.defaultPrevented, + dispatchResult, + }; + }); + expect(dirtyBeforeUnload).toEqual({ + defaultPrevented: true, + dispatchResult: false, + }); +}); + test("admin publish saves pending map edits before publishing", async ({ page }) => { await mockMapShell(page);