From 6f57b485d8efd806d6d5b4fdd3481c9a8095a8df Mon Sep 17 00:00:00 2001 From: Nico Date: Wed, 3 Jun 2026 01:59:28 -0700 Subject: [PATCH] feat: preserve draft map previews --- .../maps/LocationMapBottomSheet.jsx | 7 +- frontend/src/pages/LocationMapManager.jsx | 121 ++++++++++-------- .../src/styles/pages/LocationMapManager.css | 8 +- frontend/tests/location-map-manager.spec.ts | 9 ++ 4 files changed, 91 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/maps/LocationMapBottomSheet.jsx b/frontend/src/components/maps/LocationMapBottomSheet.jsx index 0fdbcec..061054f 100644 --- a/frontend/src/components/maps/LocationMapBottomSheet.jsx +++ b/frontend/src/components/maps/LocationMapBottomSheet.jsx @@ -29,12 +29,14 @@ export default function LocationMapBottomSheet({ history, future, saving, + hasUnsavedChanges, mapState, onAddObject, onUndo, onRedo, onSaveDraft, onPublish, + onPreviewDraft, onPanMode, onEditObjects, onEditMode, @@ -93,8 +95,9 @@ export default function LocationMapBottomSheet({ {mode === "edit" && canManage ? (
- - + + +
diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx index 2f0d063..6efe2f6 100644 --- a/frontend/src/pages/LocationMapManager.jsx +++ b/frontend/src/pages/LocationMapManager.jsx @@ -58,6 +58,7 @@ export default function LocationMapManager() { const [selectedObjectKey, setSelectedObjectKey] = useState(null); const [filters, setFilters] = useState(DEFAULT_MAP_FILTERS); const [layersOpen, setLayersOpen] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [zoom, setZoom] = useState(0.75); const [history, setHistory] = useState([]); const [future, setFuture] = useState([]); @@ -80,6 +81,23 @@ export default function LocationMapManager() { height: mapDraft.height || activeMap?.height || DEFAULT_MAP_SIZE.height, }; + const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft) => { + const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft); + const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft); + if (!nextMap) return; + + setMapDraft({ + name: nextMap.name || "Store Map", + width: nextMap.width || DEFAULT_MAP_SIZE.width, + height: nextMap.height || DEFAULT_MAP_SIZE.height, + }); + setObjects(nextObjects.map(normalizeMapObject)); + setSelectedObjectKey(null); + setHistory([]); + setFuture([]); + setHasUnsavedChanges(false); + }, []); + const loadMap = useCallback(async () => { if (!activeHousehold?.id || !locationId) return; @@ -94,6 +112,7 @@ export default function LocationMapManager() { setEditorTool("pan"); setPreviewDraft(false); setObjects([]); + setHasUnsavedChanges(false); setMapDraft({ name: "Store Map", width: DEFAULT_MAP_SIZE.width, @@ -112,18 +131,10 @@ export default function LocationMapManager() { setPreviewDraft(true); } - const nextMap = nextState.published_map || nextState.draft_map; - setMapDraft({ - name: nextMap?.name || "Store Map", - width: nextMap?.width || DEFAULT_MAP_SIZE.width, - height: nextMap?.height || DEFAULT_MAP_SIZE.height, - }); - setObjects( - objectsForMode( - nextState, - nextState.published_map ? "view" : "edit", - !nextState.published_map - ).map(normalizeMapObject) + syncMapDraftFromState( + nextState, + nextState.published_map ? "view" : "edit", + !nextState.published_map ); } catch (error) { const message = getApiErrorMessage(error, "Failed to load map"); @@ -132,7 +143,7 @@ export default function LocationMapManager() { } finally { setLoading(false); } - }, [activeHousehold?.id, locationId]); + }, [activeHousehold?.id, locationId, syncMapDraftFromState]); useEffect(() => { toastRef.current = toast; @@ -151,23 +162,6 @@ export default function LocationMapManager() { } }, [activeStore?.id, locationId, setActiveStore, stores]); - useEffect(() => { - if (!mapState) return; - const nextMap = mapForMode(mapState, mode, previewDraft); - const nextObjects = objectsForMode(mapState, mode, previewDraft); - if (!nextMap) return; - - setMapDraft({ - name: nextMap.name || "Store Map", - width: nextMap.width || DEFAULT_MAP_SIZE.width, - height: nextMap.height || DEFAULT_MAP_SIZE.height, - }); - setObjects(nextObjects.map(normalizeMapObject)); - setSelectedObjectKey(null); - setHistory([]); - setFuture([]); - }, [mapState, mode, previewDraft]); - useEffect(() => { if (mode !== "edit") { setEditorTool("pan"); @@ -185,6 +179,7 @@ export default function LocationMapManager() { }; const updateObjects = (updater) => { + setHasUnsavedChanges(true); setObjects((currentObjects) => { const nextObjects = updater(currentObjects).map((object) => clampObjectToMap(object, mapSize)); return nextObjects; @@ -200,6 +195,7 @@ export default function LocationMapManager() { setMode("edit"); setEditorTool("edit"); setPreviewDraft(true); + syncMapDraftFromState(response.data, "edit", true); toast.success("Created map", "Blank draft map created"); } catch (error) { toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map")); @@ -217,6 +213,7 @@ export default function LocationMapManager() { setMode("edit"); setEditorTool("edit"); setPreviewDraft(true); + syncMapDraftFromState(response.data, "edit", true); toast.success("Created starter map", "Zones were added as editable rectangles"); } catch (error) { toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map from zones")); @@ -236,6 +233,7 @@ export default function LocationMapManager() { setMapState(response.data); setMode("edit"); setPreviewDraft(true); + syncMapDraftFromState(response.data, "edit", true); toast.success("Saved draft", "Map draft saved"); } catch (error) { toast.error("Save draft failed", getApiErrorMessage(error, "Failed to save map draft")); @@ -253,6 +251,7 @@ export default function LocationMapManager() { setMode("view"); setEditorTool("pan"); setPreviewDraft(false); + syncMapDraftFromState(response.data, "view", false); toast.success("Published map", "Map is now visible to household members"); } catch (error) { toast.error("Publish failed", getApiErrorMessage(error, "Failed to publish map")); @@ -270,6 +269,7 @@ export default function LocationMapManager() { setObjects((currentObjects) => [...currentObjects, nextObject]); setSelectedObjectKey(getObjectKey(nextObject)); setEditorTool("edit"); + setHasUnsavedChanges(true); }; const handleDuplicateObject = () => { @@ -287,6 +287,7 @@ export default function LocationMapManager() { setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]); setSelectedObjectKey(getObjectKey(nextObject)); setEditorTool("edit"); + setHasUnsavedChanges(true); }; const requestDeleteObject = () => { @@ -301,6 +302,7 @@ export default function LocationMapManager() { currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(pendingDeleteObject)) ); setSelectedObjectKey(null); + setHasUnsavedChanges(true); toast.success( "Deleted map area", "Save the draft to keep this change." @@ -342,6 +344,7 @@ export default function LocationMapManager() { const priorObjects = previous[previous.length - 1]; setFuture((nextFuture) => [objects.map((object) => ({ ...object })), ...nextFuture.slice(0, 19)]); setObjects(priorObjects); + setHasUnsavedChanges(true); return previous.slice(0, -1); }); }; @@ -352,10 +355,41 @@ export default function LocationMapManager() { const nextObjects = previous[0]; setHistory((nextHistory) => [...nextHistory.slice(-19), objects.map((object) => ({ ...object }))]); setObjects(nextObjects); + setHasUnsavedChanges(true); return previous.slice(1); }); }; + const handlePreviewDraft = () => { + setMode("view"); + setEditorTool("pan"); + setPreviewDraft(true); + setSelectedObjectKey(null); + }; + + const handleViewMode = () => { + setMode("view"); + setEditorTool("pan"); + if (hasUnsavedChanges) { + setPreviewDraft(true); + setSelectedObjectKey(null); + return; + } + setPreviewDraft(false); + if (mapState) { + syncMapDraftFromState(mapState, "view", false); + } + }; + + const handleEditMode = () => { + setMode("edit"); + setEditorTool("pan"); + setPreviewDraft(true); + if (!previewDraft && mapState && !hasUnsavedChanges) { + syncMapDraftFromState(mapState, "edit", true); + } + }; + const handleFitMap = () => { const availableWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width @@ -383,7 +417,7 @@ export default function LocationMapManager() { } const hasAnyMap = Boolean(mapState?.draft_map || mapState?.published_map); - const status = getMapStatus(mapState); + const status = hasUnsavedChanges ? "Unsaved Draft" : getMapStatus(mapState); return (
@@ -398,16 +432,8 @@ export default function LocationMapManager() { zoom={zoom} setZoom={setZoom} onFit={handleFitMap} - onView={() => { - setMode("view"); - setEditorTool("pan"); - setPreviewDraft(false); - }} - onEdit={() => { - setMode("edit"); - setEditorTool("pan"); - setPreviewDraft(true); - }} + onView={handleViewMode} + onEdit={handleEditMode} onPanMode={() => setEditorTool("pan")} onEditObjects={() => setEditorTool("edit")} /> @@ -418,10 +444,7 @@ export default function LocationMapManager() { canManage={canManage} saving={saving} onContinue={() => setMode("edit")} - onPreview={() => { - setMode("view"); - setPreviewDraft(true); - }} + onPreview={handlePreviewDraft} onPublish={handlePublish} onCreateFromZones={handleCreateFromZones} onCreateBlank={handleCreateBlank} @@ -459,19 +482,17 @@ export default function LocationMapManager() { history={history} future={future} saving={saving} + hasUnsavedChanges={hasUnsavedChanges} mapState={mapState} onAddObject={handleAddObject} onUndo={handleUndo} onRedo={handleRedo} onSaveDraft={handleSaveDraft} onPublish={handlePublish} + onPreviewDraft={handlePreviewDraft} onPanMode={() => setEditorTool("pan")} onEditObjects={() => setEditorTool("edit")} - onEditMode={() => { - setMode("edit"); - setEditorTool("pan"); - setPreviewDraft(true); - }} + onEditMode={handleEditMode} onObjectField={handleObjectField} onZoneLinkChange={handleZoneLinkChange} onDuplicateObject={handleDuplicateObject} diff --git a/frontend/src/styles/pages/LocationMapManager.css b/frontend/src/styles/pages/LocationMapManager.css index 89d49fe..7da36c6 100644 --- a/frontend/src/styles/pages/LocationMapManager.css +++ b/frontend/src/styles/pages/LocationMapManager.css @@ -78,6 +78,11 @@ border-color: rgba(245, 158, 11, 0.36); } +.location-map-status-unsaved-draft { + background: rgba(244, 114, 182, 0.2); + border-color: rgba(244, 114, 182, 0.42); +} + .location-map-status-published { background: rgba(20, 184, 166, 0.18); border-color: rgba(20, 184, 166, 0.34); @@ -137,8 +142,7 @@ .location-map-mode-buttons button.active, .location-map-tool-buttons button.active, -.location-map-primary-actions button:nth-child(2), -.location-map-primary-actions button:nth-child(3), +.location-map-editor-actions button.primary, .location-map-setup-actions .btn-primary { border-color: var(--color-primary); background: var(--color-primary); diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index 3b42c92..97b0eb9 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -254,7 +254,16 @@ test("admin creates a map from zones, saves a draft, and publishes it", async ({ const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first(); await bakeryObject.click(); await page.getByRole("textbox", { name: "Label" }).fill("Bread Wall"); + await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); + await page.getByRole("button", { name: "Preview Draft" }).click(); + await expect(page.locator(".location-map-object", { hasText: "Bread Wall" })).toBeVisible(); + await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); + + await page.getByRole("button", { name: "Edit Draft" }).click(); + await page.getByRole("button", { name: "Edit Objects" }).click(); + await page.locator(".location-map-object", { hasText: "Bread Wall" }).locator("rect").first().click(); await page.getByRole("button", { name: "Save Draft" }).click(); + await expect(page.locator(".location-map-status")).toHaveText("Draft"); expect(savedPayload).not.toBeNull(); expect(savedPayload?.objects).toEqual(