Add store location map manager #20
@ -29,12 +29,14 @@ export default function LocationMapBottomSheet({
|
|||||||
history,
|
history,
|
||||||
future,
|
future,
|
||||||
saving,
|
saving,
|
||||||
|
hasUnsavedChanges,
|
||||||
mapState,
|
mapState,
|
||||||
onAddObject,
|
onAddObject,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
onSaveDraft,
|
onSaveDraft,
|
||||||
onPublish,
|
onPublish,
|
||||||
|
onPreviewDraft,
|
||||||
onPanMode,
|
onPanMode,
|
||||||
onEditObjects,
|
onEditObjects,
|
||||||
onEditMode,
|
onEditMode,
|
||||||
@ -93,8 +95,9 @@ export default function LocationMapBottomSheet({
|
|||||||
{mode === "edit" && canManage ? (
|
{mode === "edit" && canManage ? (
|
||||||
<div className="location-map-editor-actions location-map-primary-actions">
|
<div className="location-map-editor-actions location-map-primary-actions">
|
||||||
<button type="button" onClick={onAddObject}>Add Area</button>
|
<button type="button" onClick={onAddObject}>Add Area</button>
|
||||||
<button type="button" onClick={onSaveDraft} disabled={saving}>Save Draft</button>
|
<button type="button" onClick={onPreviewDraft}>Preview Draft</button>
|
||||||
<button type="button" onClick={onPublish} disabled={saving || !mapState?.draft_map}>Publish</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={onUndo} disabled={history.length === 0}>Undo</button>
|
||||||
<button type="button" onClick={onRedo} disabled={future.length === 0}>Redo</button>
|
<button type="button" onClick={onRedo} disabled={future.length === 0}>Redo</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -58,6 +58,7 @@ export default function LocationMapManager() {
|
|||||||
const [selectedObjectKey, setSelectedObjectKey] = useState(null);
|
const [selectedObjectKey, setSelectedObjectKey] = useState(null);
|
||||||
const [filters, setFilters] = useState(DEFAULT_MAP_FILTERS);
|
const [filters, setFilters] = useState(DEFAULT_MAP_FILTERS);
|
||||||
const [layersOpen, setLayersOpen] = useState(false);
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
const [zoom, setZoom] = useState(0.75);
|
const [zoom, setZoom] = useState(0.75);
|
||||||
const [history, setHistory] = useState([]);
|
const [history, setHistory] = useState([]);
|
||||||
const [future, setFuture] = useState([]);
|
const [future, setFuture] = useState([]);
|
||||||
@ -80,6 +81,23 @@ export default function LocationMapManager() {
|
|||||||
height: mapDraft.height || activeMap?.height || DEFAULT_MAP_SIZE.height,
|
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 () => {
|
const loadMap = useCallback(async () => {
|
||||||
if (!activeHousehold?.id || !locationId) return;
|
if (!activeHousehold?.id || !locationId) return;
|
||||||
|
|
||||||
@ -94,6 +112,7 @@ export default function LocationMapManager() {
|
|||||||
setEditorTool("pan");
|
setEditorTool("pan");
|
||||||
setPreviewDraft(false);
|
setPreviewDraft(false);
|
||||||
setObjects([]);
|
setObjects([]);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
setMapDraft({
|
setMapDraft({
|
||||||
name: "Store Map",
|
name: "Store Map",
|
||||||
width: DEFAULT_MAP_SIZE.width,
|
width: DEFAULT_MAP_SIZE.width,
|
||||||
@ -112,18 +131,10 @@ export default function LocationMapManager() {
|
|||||||
setPreviewDraft(true);
|
setPreviewDraft(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMap = nextState.published_map || nextState.draft_map;
|
syncMapDraftFromState(
|
||||||
setMapDraft({
|
nextState,
|
||||||
name: nextMap?.name || "Store Map",
|
nextState.published_map ? "view" : "edit",
|
||||||
width: nextMap?.width || DEFAULT_MAP_SIZE.width,
|
!nextState.published_map
|
||||||
height: nextMap?.height || DEFAULT_MAP_SIZE.height,
|
|
||||||
});
|
|
||||||
setObjects(
|
|
||||||
objectsForMode(
|
|
||||||
nextState,
|
|
||||||
nextState.published_map ? "view" : "edit",
|
|
||||||
!nextState.published_map
|
|
||||||
).map(normalizeMapObject)
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, "Failed to load map");
|
const message = getApiErrorMessage(error, "Failed to load map");
|
||||||
@ -132,7 +143,7 @@ export default function LocationMapManager() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, locationId]);
|
}, [activeHousehold?.id, locationId, syncMapDraftFromState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
toastRef.current = toast;
|
toastRef.current = toast;
|
||||||
@ -151,23 +162,6 @@ export default function LocationMapManager() {
|
|||||||
}
|
}
|
||||||
}, [activeStore?.id, locationId, setActiveStore, stores]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (mode !== "edit") {
|
if (mode !== "edit") {
|
||||||
setEditorTool("pan");
|
setEditorTool("pan");
|
||||||
@ -185,6 +179,7 @@ export default function LocationMapManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateObjects = (updater) => {
|
const updateObjects = (updater) => {
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
setObjects((currentObjects) => {
|
setObjects((currentObjects) => {
|
||||||
const nextObjects = updater(currentObjects).map((object) => clampObjectToMap(object, mapSize));
|
const nextObjects = updater(currentObjects).map((object) => clampObjectToMap(object, mapSize));
|
||||||
return nextObjects;
|
return nextObjects;
|
||||||
@ -200,6 +195,7 @@ export default function LocationMapManager() {
|
|||||||
setMode("edit");
|
setMode("edit");
|
||||||
setEditorTool("edit");
|
setEditorTool("edit");
|
||||||
setPreviewDraft(true);
|
setPreviewDraft(true);
|
||||||
|
syncMapDraftFromState(response.data, "edit", true);
|
||||||
toast.success("Created map", "Blank draft map created");
|
toast.success("Created map", "Blank draft map created");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map"));
|
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map"));
|
||||||
@ -217,6 +213,7 @@ export default function LocationMapManager() {
|
|||||||
setMode("edit");
|
setMode("edit");
|
||||||
setEditorTool("edit");
|
setEditorTool("edit");
|
||||||
setPreviewDraft(true);
|
setPreviewDraft(true);
|
||||||
|
syncMapDraftFromState(response.data, "edit", true);
|
||||||
toast.success("Created starter map", "Zones were added as editable rectangles");
|
toast.success("Created starter map", "Zones were added as editable rectangles");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map from zones"));
|
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map from zones"));
|
||||||
@ -236,6 +233,7 @@ export default function LocationMapManager() {
|
|||||||
setMapState(response.data);
|
setMapState(response.data);
|
||||||
setMode("edit");
|
setMode("edit");
|
||||||
setPreviewDraft(true);
|
setPreviewDraft(true);
|
||||||
|
syncMapDraftFromState(response.data, "edit", true);
|
||||||
toast.success("Saved draft", "Map draft saved");
|
toast.success("Saved draft", "Map draft saved");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Save draft failed", getApiErrorMessage(error, "Failed to save map draft"));
|
toast.error("Save draft failed", getApiErrorMessage(error, "Failed to save map draft"));
|
||||||
@ -253,6 +251,7 @@ export default function LocationMapManager() {
|
|||||||
setMode("view");
|
setMode("view");
|
||||||
setEditorTool("pan");
|
setEditorTool("pan");
|
||||||
setPreviewDraft(false);
|
setPreviewDraft(false);
|
||||||
|
syncMapDraftFromState(response.data, "view", false);
|
||||||
toast.success("Published map", "Map is now visible to household members");
|
toast.success("Published map", "Map is now visible to household members");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Publish failed", getApiErrorMessage(error, "Failed to publish map"));
|
toast.error("Publish failed", getApiErrorMessage(error, "Failed to publish map"));
|
||||||
@ -270,6 +269,7 @@ export default function LocationMapManager() {
|
|||||||
setObjects((currentObjects) => [...currentObjects, nextObject]);
|
setObjects((currentObjects) => [...currentObjects, nextObject]);
|
||||||
setSelectedObjectKey(getObjectKey(nextObject));
|
setSelectedObjectKey(getObjectKey(nextObject));
|
||||||
setEditorTool("edit");
|
setEditorTool("edit");
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicateObject = () => {
|
const handleDuplicateObject = () => {
|
||||||
@ -287,6 +287,7 @@ export default function LocationMapManager() {
|
|||||||
setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]);
|
setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]);
|
||||||
setSelectedObjectKey(getObjectKey(nextObject));
|
setSelectedObjectKey(getObjectKey(nextObject));
|
||||||
setEditorTool("edit");
|
setEditorTool("edit");
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestDeleteObject = () => {
|
const requestDeleteObject = () => {
|
||||||
@ -301,6 +302,7 @@ export default function LocationMapManager() {
|
|||||||
currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(pendingDeleteObject))
|
currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(pendingDeleteObject))
|
||||||
);
|
);
|
||||||
setSelectedObjectKey(null);
|
setSelectedObjectKey(null);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
toast.success(
|
toast.success(
|
||||||
"Deleted map area",
|
"Deleted map area",
|
||||||
"Save the draft to keep this change."
|
"Save the draft to keep this change."
|
||||||
@ -342,6 +344,7 @@ export default function LocationMapManager() {
|
|||||||
const priorObjects = previous[previous.length - 1];
|
const priorObjects = previous[previous.length - 1];
|
||||||
setFuture((nextFuture) => [objects.map((object) => ({ ...object })), ...nextFuture.slice(0, 19)]);
|
setFuture((nextFuture) => [objects.map((object) => ({ ...object })), ...nextFuture.slice(0, 19)]);
|
||||||
setObjects(priorObjects);
|
setObjects(priorObjects);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
return previous.slice(0, -1);
|
return previous.slice(0, -1);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -352,10 +355,41 @@ export default function LocationMapManager() {
|
|||||||
const nextObjects = previous[0];
|
const nextObjects = previous[0];
|
||||||
setHistory((nextHistory) => [...nextHistory.slice(-19), objects.map((object) => ({ ...object }))]);
|
setHistory((nextHistory) => [...nextHistory.slice(-19), objects.map((object) => ({ ...object }))]);
|
||||||
setObjects(nextObjects);
|
setObjects(nextObjects);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
return previous.slice(1);
|
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 handleFitMap = () => {
|
||||||
const availableWidth = typeof window === "undefined"
|
const availableWidth = typeof window === "undefined"
|
||||||
? DEFAULT_MAP_SIZE.width
|
? DEFAULT_MAP_SIZE.width
|
||||||
@ -383,7 +417,7 @@ export default function LocationMapManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasAnyMap = Boolean(mapState?.draft_map || mapState?.published_map);
|
const hasAnyMap = Boolean(mapState?.draft_map || mapState?.published_map);
|
||||||
const status = getMapStatus(mapState);
|
const status = hasUnsavedChanges ? "Unsaved Draft" : getMapStatus(mapState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="location-map-page">
|
<div className="location-map-page">
|
||||||
@ -398,16 +432,8 @@ export default function LocationMapManager() {
|
|||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
setZoom={setZoom}
|
setZoom={setZoom}
|
||||||
onFit={handleFitMap}
|
onFit={handleFitMap}
|
||||||
onView={() => {
|
onView={handleViewMode}
|
||||||
setMode("view");
|
onEdit={handleEditMode}
|
||||||
setEditorTool("pan");
|
|
||||||
setPreviewDraft(false);
|
|
||||||
}}
|
|
||||||
onEdit={() => {
|
|
||||||
setMode("edit");
|
|
||||||
setEditorTool("pan");
|
|
||||||
setPreviewDraft(true);
|
|
||||||
}}
|
|
||||||
onPanMode={() => setEditorTool("pan")}
|
onPanMode={() => setEditorTool("pan")}
|
||||||
onEditObjects={() => setEditorTool("edit")}
|
onEditObjects={() => setEditorTool("edit")}
|
||||||
/>
|
/>
|
||||||
@ -418,10 +444,7 @@ export default function LocationMapManager() {
|
|||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onContinue={() => setMode("edit")}
|
onContinue={() => setMode("edit")}
|
||||||
onPreview={() => {
|
onPreview={handlePreviewDraft}
|
||||||
setMode("view");
|
|
||||||
setPreviewDraft(true);
|
|
||||||
}}
|
|
||||||
onPublish={handlePublish}
|
onPublish={handlePublish}
|
||||||
onCreateFromZones={handleCreateFromZones}
|
onCreateFromZones={handleCreateFromZones}
|
||||||
onCreateBlank={handleCreateBlank}
|
onCreateBlank={handleCreateBlank}
|
||||||
@ -459,19 +482,17 @@ export default function LocationMapManager() {
|
|||||||
history={history}
|
history={history}
|
||||||
future={future}
|
future={future}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
mapState={mapState}
|
mapState={mapState}
|
||||||
onAddObject={handleAddObject}
|
onAddObject={handleAddObject}
|
||||||
onUndo={handleUndo}
|
onUndo={handleUndo}
|
||||||
onRedo={handleRedo}
|
onRedo={handleRedo}
|
||||||
onSaveDraft={handleSaveDraft}
|
onSaveDraft={handleSaveDraft}
|
||||||
onPublish={handlePublish}
|
onPublish={handlePublish}
|
||||||
|
onPreviewDraft={handlePreviewDraft}
|
||||||
onPanMode={() => setEditorTool("pan")}
|
onPanMode={() => setEditorTool("pan")}
|
||||||
onEditObjects={() => setEditorTool("edit")}
|
onEditObjects={() => setEditorTool("edit")}
|
||||||
onEditMode={() => {
|
onEditMode={handleEditMode}
|
||||||
setMode("edit");
|
|
||||||
setEditorTool("pan");
|
|
||||||
setPreviewDraft(true);
|
|
||||||
}}
|
|
||||||
onObjectField={handleObjectField}
|
onObjectField={handleObjectField}
|
||||||
onZoneLinkChange={handleZoneLinkChange}
|
onZoneLinkChange={handleZoneLinkChange}
|
||||||
onDuplicateObject={handleDuplicateObject}
|
onDuplicateObject={handleDuplicateObject}
|
||||||
|
|||||||
@ -78,6 +78,11 @@
|
|||||||
border-color: rgba(245, 158, 11, 0.36);
|
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 {
|
.location-map-status-published {
|
||||||
background: rgba(20, 184, 166, 0.18);
|
background: rgba(20, 184, 166, 0.18);
|
||||||
border-color: rgba(20, 184, 166, 0.34);
|
border-color: rgba(20, 184, 166, 0.34);
|
||||||
@ -137,8 +142,7 @@
|
|||||||
|
|
||||||
.location-map-mode-buttons button.active,
|
.location-map-mode-buttons button.active,
|
||||||
.location-map-tool-buttons button.active,
|
.location-map-tool-buttons button.active,
|
||||||
.location-map-primary-actions button:nth-child(2),
|
.location-map-editor-actions button.primary,
|
||||||
.location-map-primary-actions button:nth-child(3),
|
|
||||||
.location-map-setup-actions .btn-primary {
|
.location-map-setup-actions .btn-primary {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
background: 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();
|
const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
|
||||||
await bakeryObject.click();
|
await bakeryObject.click();
|
||||||
await page.getByRole("textbox", { name: "Label" }).fill("Bread Wall");
|
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 page.getByRole("button", { name: "Save Draft" }).click();
|
||||||
|
await expect(page.locator(".location-map-status")).toHaveText("Draft");
|
||||||
|
|
||||||
expect(savedPayload).not.toBeNull();
|
expect(savedPayload).not.toBeNull();
|
||||||
expect(savedPayload?.objects).toEqual(
|
expect(savedPayload?.objects).toEqual(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user