feat: preserve draft map previews

This commit is contained in:
Nico 2026-06-03 01:59:28 -07:00
parent b739be29ba
commit 6f57b485d8
4 changed files with 91 additions and 54 deletions

View File

@ -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 ? (
<div className="location-map-editor-actions location-map-primary-actions">
<button type="button" onClick={onAddObject}>Add Area</button>
<button type="button" onClick={onSaveDraft} disabled={saving}>Save Draft</button>
<button type="button" onClick={onPublish} disabled={saving || !mapState?.draft_map}>Publish</button>
<button type="button" onClick={onPreviewDraft}>Preview Draft</button>
<button type="button" className="primary" onClick={onSaveDraft} disabled={saving || !hasUnsavedChanges}>Save Draft</button>
<button type="button" className="primary" onClick={onPublish} disabled={saving || !mapState?.draft_map}>Publish</button>
<button type="button" onClick={onUndo} disabled={history.length === 0}>Undo</button>
<button type="button" onClick={onRedo} disabled={future.length === 0}>Redo</button>
</div>

View File

@ -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 (
<div className="location-map-page">
@ -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}

View File

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

View File

@ -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(