Add store location map manager #20

Open
nalalangan wants to merge 206 commits from feature/location-map-manager into feature/store-selector-modal
4 changed files with 91 additions and 54 deletions
Showing only changes of commit 6f57b485d8 - Show all commits

View File

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

View File

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

View File

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

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