fix: ignore stale map draft action responses

This commit is contained in:
Nico 2026-06-04 14:18:16 -07:00
parent 27a50d599c
commit 9772d7e043
2 changed files with 151 additions and 5 deletions

View File

@ -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,
]);

View File

@ -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<void>((resolve) => {
resolveSaveStarted = resolve;
});
const saveGate = new Promise<void>((resolve) => {
releaseSaveResponse = resolve;
});
const saveResponded = new Promise<void>((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<Record<string, unknown>>).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]);