feat: preserve draft map previews
This commit is contained in:
parent
b739be29ba
commit
6f57b485d8
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user