diff --git a/frontend/src/hooks/useLocationMapDraftActions.js b/frontend/src/hooks/useLocationMapDraftActions.js index c5d8bc6..22fa23a 100644 --- a/frontend/src/hooks/useLocationMapDraftActions.js +++ b/frontend/src/hooks/useLocationMapDraftActions.js @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { createBlankLocationMap, createLocationMapFromZones, @@ -24,10 +24,20 @@ export default function useLocationMapDraftActions({ syncMapDraftFromState, toast, }) { + const latestScopeRef = useRef({ activeHouseholdId, locationId }); const selectedObjectIndex = selectedObjectKey ? objects.findIndex((object) => getObjectKey(object) === selectedObjectKey) : -1; + useEffect(() => { + latestScopeRef.current = { activeHouseholdId, locationId }; + }, [activeHouseholdId, locationId]); + + const isCurrentScope = useCallback(() => ( + String(latestScopeRef.current.activeHouseholdId ?? "") === String(activeHouseholdId ?? "") && + String(latestScopeRef.current.locationId ?? "") === String(locationId ?? "") + ), [activeHouseholdId, locationId]); + const applyDraftResponse = useCallback((response, nextMode = "edit", nextPreviewDraft = true, options = {}) => { setMapState(response.data); setMode(nextMode); @@ -40,30 +50,40 @@ export default function useLocationMapDraftActions({ beginSaving("create-blank"); try { const response = await createBlankLocationMap(activeHouseholdId, locationId, mapDraft); + if (!isCurrentScope()) return; applyDraftResponse(response); setEditorTool("edit"); toast.success("Created map", "Blank draft map created"); } catch (error) { + if (!isCurrentScope()) return; toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map")); } finally { endSaving(); } - }, [activeHouseholdId, applyDraftResponse, beginSaving, endSaving, locationId, mapDraft, setEditorTool, toast]); + }, [ + activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope, + locationId, mapDraft, setEditorTool, toast, + ]); const handleCreateFromZones = useCallback(async () => { if (!activeHouseholdId || !locationId) return; beginSaving("create-zones"); try { const response = await createLocationMapFromZones(activeHouseholdId, locationId, mapDraft); + if (!isCurrentScope()) return; applyDraftResponse(response); setEditorTool("edit"); toast.success("Created starter map", "Zones were added as editable rectangles"); } catch (error) { + if (!isCurrentScope()) return; toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map from zones")); } finally { endSaving(); } - }, [activeHouseholdId, applyDraftResponse, beginSaving, endSaving, locationId, mapDraft, setEditorTool, toast]); + }, [ + activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope, + locationId, mapDraft, setEditorTool, toast, + ]); const handleSaveDraft = useCallback(async () => { if (!activeHouseholdId || !locationId) return; @@ -73,15 +93,17 @@ export default function useLocationMapDraftActions({ map: mapDraft, objects: prepareObjectsForSave(objects), }); + if (!isCurrentScope()) return; applyDraftResponse(response, "edit", true, { preserveSelectedIndex: selectedObjectIndex }); toast.success("Saved draft", "Map draft saved"); } catch (error) { + if (!isCurrentScope()) return; toast.error("Save draft failed", getApiErrorMessage(error, "Failed to save map draft")); } finally { endSaving(); } }, [ - activeHouseholdId, applyDraftResponse, beginSaving, endSaving, locationId, + activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope, locationId, mapDraft, objects, selectedObjectIndex, toast, ]); @@ -95,11 +117,13 @@ export default function useLocationMapDraftActions({ map: mapDraft, objects: prepareObjectsForSave(objects), }); + if (!isCurrentScope()) return; savedPendingDraft = true; applyDraftResponse(saveResponse, "edit", true, { preserveSelectedIndex: selectedObjectIndex }); } const response = await publishLocationMapDraft(activeHouseholdId, locationId); + if (!isCurrentScope()) return; applyDraftResponse(response, "view", false); setEditorTool("pan"); toast.success( @@ -109,13 +133,14 @@ export default function useLocationMapDraftActions({ : "Map is now visible to household members" ); } catch (error) { + if (!isCurrentScope()) return; const message = getApiErrorMessage(error, "Failed to publish map"); toast.error("Publish failed", savedPendingDraft ? `${message}. Draft changes were saved.` : message); } finally { endSaving(); } }, [ - activeHouseholdId, applyDraftResponse, beginSaving, endSaving, hasUnsavedChanges, + activeHouseholdId, applyDraftResponse, beginSaving, endSaving, hasUnsavedChanges, isCurrentScope, locationId, mapDraft, objects, selectedObjectIndex, setEditorTool, toast, ]); diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index 39bd3be..c32fef2 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -591,6 +591,127 @@ test("ignores stale map responses after switching locations", async ({ page }) = await expect(page.locator(".location-map-title span")).toHaveText("Ontario"); }); +test("ignores stale map save responses after switching locations", async ({ page }) => { + await mockMapShell(page, adminHousehold, [storeLocation, secondStoreLocation]); + + const firstLocationState = draftMapState([ + { + id: 483, + location_map_id: 900, + zone_id: 501, + zone_name: "Bakery", + type: "zone", + label: "Eastvale Bakery", + x: 40, + y: 40, + width: 260, + height: 160, + rotation: 0, + locked: false, + visible: true, + sort_order: 1, + }, + ], true); + const nextLocationBaseState = publishedMapState([ + { + id: 484, + location_map_id: 902, + zone_id: 502, + zone_name: "Produce", + type: "zone", + label: "Ontario Produce", + x: 80, + y: 80, + width: 280, + height: 160, + rotation: 0, + locked: false, + visible: true, + sort_order: 1, + }, + ], true); + const nextPublishedMap = { + ...nextLocationBaseState.published_map, + id: 902, + store_location_id: secondStoreLocation.id, + }; + const nextLocationState = { + ...nextLocationBaseState, + location: secondStoreLocation, + map: nextPublishedMap, + published_map: nextPublishedMap, + }; + + let releaseSaveResponse: () => void = () => {}; + let resolveSaveStarted: () => void = () => {}; + let resolveSaveResponded: () => void = () => {}; + const saveStarted = new Promise((resolve) => { + resolveSaveStarted = resolve; + }); + const saveGate = new Promise((resolve) => { + releaseSaveResponse = resolve; + }); + const saveResponded = new Promise((resolve) => { + resolveSaveResponded = resolve; + }); + + await page.route("**/households/1/locations/10/map", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(firstLocationState), + }); + }); + await page.route("**/households/1/locations/10/map/draft", async (route) => { + resolveSaveStarted(); + await saveGate; + const payload = await route.request().postDataJSON(); + const savedObjects = (payload.objects as Array>).map((object, index) => ({ + id: 1480 + index, + location_map_id: 900, + zone_name: object.zone_id === 501 ? "Bakery" : null, + ...object, + label: "Saved Eastvale Bakery", + })); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(draftMapState(savedObjects, true)), + }); + resolveSaveResponded(); + }); + await page.route("**/households/1/locations/11/map", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(nextLocationState), + }); + }); + + await page.goto("/stores/100/locations/10/map"); + await page.getByRole("button", { name: "Continue Editing" }).click(); + await page.getByRole("button", { name: "Map area Eastvale Bakery" }).click(); + await page.getByRole("textbox", { name: "Label" }).fill("Saving Eastvale Bakery"); + await page.getByRole("button", { name: "Save Draft" }).click(); + await saveStarted; + + await page.evaluate(() => { + window.history.pushState({}, "", "/stores/100/locations/11/map"); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + + await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible(); + await expect(page.locator(".location-map-title span")).toHaveText("Ontario"); + + releaseSaveResponse(); + await saveResponded; + + await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible(); + await expect(page.locator(".location-map-title span")).toHaveText("Ontario"); + await expect(page.getByRole("button", { name: "Map area Saved Eastvale Bakery" })).toHaveCount(0); + await expect(page.getByText("Saved draft")).toHaveCount(0); +}); + test("resets map layer filters after switching locations", async ({ page }) => { await mockMapShell(page, adminHousehold, [storeLocation, secondStoreLocation]);