Add store location map manager #20

Open
nalalangan wants to merge 206 commits from feature/location-map-manager into feature/store-selector-modal
7 changed files with 136 additions and 25 deletions
Showing only changes of commit fd22cc1fe8 - Show all commits

View File

@ -29,6 +29,7 @@ export default function LocationMapBottomSheet({
history, history,
future, future,
saving, saving,
savingAction,
hasUnsavedChanges, hasUnsavedChanges,
mapState, mapState,
onAddObject, onAddObject,
@ -74,6 +75,8 @@ export default function LocationMapBottomSheet({
const changedLayerCount = DISPLAY_CONTROLS.filter(([key]) => filters[key] !== DEFAULT_MAP_FILTERS[key]).length; const changedLayerCount = DISPLAY_CONTROLS.filter(([key]) => filters[key] !== DEFAULT_MAP_FILTERS[key]).length;
const unmappedItemPreview = visibleUnmappedItems.slice(0, 8); const unmappedItemPreview = visibleUnmappedItems.slice(0, 8);
const hiddenUnmappedItemCount = visibleUnmappedItems.length - unmappedItemPreview.length; const hiddenUnmappedItemCount = visibleUnmappedItems.length - unmappedItemPreview.length;
const saveDraftLabel = savingAction === "save" ? "Saving..." : "Save Draft";
const publishLabel = savingAction === "publish" ? "Publishing..." : "Publish";
const showSelectedZoneItems = () => { const showSelectedZoneItems = () => {
setFilters((current) => ({ setFilters((current) => ({
...current, ...current,
@ -85,21 +88,23 @@ export default function LocationMapBottomSheet({
const editorActions = showEditorControls ? ( const editorActions = showEditorControls ? (
<div className="location-map-editor-action-stack" role="group" aria-label="Map draft actions"> <div className="location-map-editor-action-stack" role="group" aria-label="Map draft actions">
<div className="location-map-editor-actions location-map-primary-actions"> <div className="location-map-editor-actions location-map-primary-actions">
<button type="button" className="primary" onClick={onSaveDraft} disabled={saving || !hasUnsavedChanges}>Save Draft</button> <button type="button" className="primary" onClick={onSaveDraft} disabled={saving || !hasUnsavedChanges}>
{saveDraftLabel}
</button>
<button <button
type="button" type="button"
className="primary" className="primary"
onClick={onPublish} onClick={onPublish}
disabled={saving || (!mapState?.draft_map && !hasUnsavedChanges)} disabled={saving || (!mapState?.draft_map && !hasUnsavedChanges)}
> >
Publish {publishLabel}
</button> </button>
</div> </div>
<div className="location-map-editor-actions location-map-secondary-actions"> <div className="location-map-editor-actions location-map-secondary-actions">
<button type="button" onClick={onAddObject}>Add Area</button> <button type="button" onClick={onAddObject} disabled={saving}>Add Area</button>
<button type="button" onClick={onPreviewDraft} aria-label="Preview Draft">Preview</button> <button type="button" onClick={onPreviewDraft} aria-label="Preview Draft" disabled={saving}>Preview</button>
<button type="button" onClick={onUndo} disabled={history.length === 0}>Undo</button> <button type="button" onClick={onUndo} disabled={saving || history.length === 0}>Undo</button>
<button type="button" onClick={onRedo} disabled={future.length === 0}>Redo</button> <button type="button" onClick={onRedo} disabled={saving || future.length === 0}>Redo</button>
</div> </div>
</div> </div>
) : canManage && mapState?.published_map ? ( ) : canManage && mapState?.published_map ? (
@ -172,6 +177,7 @@ export default function LocationMapBottomSheet({
type="button" type="button"
className={[editorTool === "pan" ? "active" : "", "is-pan"].filter(Boolean).join(" ")} className={[editorTool === "pan" ? "active" : "", "is-pan"].filter(Boolean).join(" ")}
onClick={onPanMode} onClick={onPanMode}
disabled={saving}
> >
Pan Pan
</button> </button>
@ -179,6 +185,7 @@ export default function LocationMapBottomSheet({
type="button" type="button"
className={[editorTool === "edit" ? "active" : "", "is-edit"].filter(Boolean).join(" ")} className={[editorTool === "edit" ? "active" : "", "is-edit"].filter(Boolean).join(" ")}
onClick={onEditObjects} onClick={onEditObjects}
disabled={saving}
> >
Objects Objects
</button> </button>
@ -191,6 +198,7 @@ export default function LocationMapBottomSheet({
Label Label
<input <input
value={selectedObject.label || ""} value={selectedObject.label || ""}
disabled={saving}
onChange={(event) => onObjectField("label", event.target.value)} onChange={(event) => onObjectField("label", event.target.value)}
/> />
</label> </label>
@ -199,6 +207,7 @@ export default function LocationMapBottomSheet({
<select <select
aria-label="Object type" aria-label="Object type"
value={selectedObject.type} value={selectedObject.type}
disabled={saving}
onChange={(event) => onObjectField("type", event.target.value)} onChange={(event) => onObjectField("type", event.target.value)}
> >
{MAP_OBJECT_TYPES.map((type) => ( {MAP_OBJECT_TYPES.map((type) => (
@ -211,6 +220,7 @@ export default function LocationMapBottomSheet({
<select <select
aria-label="Linked zone" aria-label="Linked zone"
value={objectZoneId(selectedObject)} value={objectZoneId(selectedObject)}
disabled={saving}
onChange={(event) => onZoneLinkChange(event.target.value)} onChange={(event) => onZoneLinkChange(event.target.value)}
> >
<option value="">No linked zone</option> <option value="">No linked zone</option>
@ -225,6 +235,7 @@ export default function LocationMapBottomSheet({
<input <input
type="number" type="number"
value={Math.round(selectedObject.x)} value={Math.round(selectedObject.x)}
disabled={saving}
onChange={(event) => onObjectField("x", Number(event.target.value))} onChange={(event) => onObjectField("x", Number(event.target.value))}
/> />
</label> </label>
@ -233,6 +244,7 @@ export default function LocationMapBottomSheet({
<input <input
type="number" type="number"
value={Math.round(selectedObject.y)} value={Math.round(selectedObject.y)}
disabled={saving}
onChange={(event) => onObjectField("y", Number(event.target.value))} onChange={(event) => onObjectField("y", Number(event.target.value))}
/> />
</label> </label>
@ -241,6 +253,7 @@ export default function LocationMapBottomSheet({
<input <input
type="number" type="number"
value={Math.round(selectedObject.width)} value={Math.round(selectedObject.width)}
disabled={saving}
onChange={(event) => onObjectField("width", Number(event.target.value))} onChange={(event) => onObjectField("width", Number(event.target.value))}
/> />
</label> </label>
@ -249,14 +262,15 @@ export default function LocationMapBottomSheet({
<input <input
type="number" type="number"
value={Math.round(selectedObject.height)} value={Math.round(selectedObject.height)}
disabled={saving}
onChange={(event) => onObjectField("height", Number(event.target.value))} onChange={(event) => onObjectField("height", Number(event.target.value))}
/> />
</label> </label>
</div> </div>
<div className="location-map-editor-actions"> <div className="location-map-editor-actions">
<button type="button" onClick={onClearSelection}>Done</button> <button type="button" onClick={onClearSelection}>Done</button>
<button type="button" onClick={onDuplicateObject}>Duplicate</button> <button type="button" onClick={onDuplicateObject} disabled={saving}>Duplicate</button>
<button type="button" className="danger" onClick={onDeleteObject}>Delete</button> <button type="button" className="danger" onClick={onDeleteObject} disabled={saving}>Delete</button>
</div> </div>
</div> </div>
) : mode === "edit" && editorTool === "edit" ? ( ) : mode === "edit" && editorTool === "edit" ? (

View File

@ -19,12 +19,13 @@ export default function LocationMapCanvas({
username, username,
svgRef, svgRef,
dragState, dragState,
editingLocked = false,
setDragState, setDragState,
setSelectedObjectKey, setSelectedObjectKey,
remember, remember,
updateObjects, updateObjects,
}) { }) {
const canEditObjects = mode === "edit" && editorTool === "edit"; const canEditObjects = !editingLocked && mode === "edit" && editorTool === "edit";
const canDragPan = mode === "view" || editorTool === "pan"; const canDragPan = mode === "view" || editorTool === "pan";
const scrollRef = useRef(null); const scrollRef = useRef(null);
const panDragRef = useRef(null); const panDragRef = useRef(null);

View File

@ -3,6 +3,7 @@ export default function LocationMapSetupPanel({
canManage, canManage,
zoneCount = 0, zoneCount = 0,
saving, saving,
savingAction,
onContinue, onContinue,
onPreview, onPreview,
onPublish, onPublish,
@ -20,6 +21,9 @@ export default function LocationMapSetupPanel({
const createFromZonesLabel = hasZones const createFromZonesLabel = hasZones
? `Create From ${zoneCount} Zone${zoneCount === 1 ? "" : "s"}` ? `Create From ${zoneCount} Zone${zoneCount === 1 ? "" : "s"}`
: "Create From Zones"; : "Create From Zones";
const createBlankLabel = savingAction === "create-blank" ? "Creating..." : "Create Blank Map";
const createZonesButtonLabel = savingAction === "create-zones" ? "Creating..." : createFromZonesLabel;
const publishLabel = savingAction === "publish" ? "Publishing..." : "Publish Map";
return ( return (
<section className="location-map-setup"> <section className="location-map-setup">
@ -27,14 +31,14 @@ export default function LocationMapSetupPanel({
<p>{description}</p> <p>{description}</p>
{hasAnyMap && canManage ? ( {hasAnyMap && canManage ? (
<div className="location-map-setup-actions"> <div className="location-map-setup-actions">
<button type="button" className="btn-primary" onClick={onContinue}> <button type="button" className="btn-primary" onClick={onContinue} disabled={saving}>
Continue Editing Continue Editing
</button> </button>
<button type="button" className="btn-secondary" onClick={onPreview}> <button type="button" className="btn-secondary" onClick={onPreview} disabled={saving}>
Preview Map Preview Map
</button> </button>
<button type="button" className="btn-primary" onClick={onPublish} disabled={saving || !canManage}> <button type="button" className="btn-primary" onClick={onPublish} disabled={saving || !canManage}>
Publish Map {publishLabel}
</button> </button>
</div> </div>
) : !hasAnyMap && canManage ? ( ) : !hasAnyMap && canManage ? (
@ -47,7 +51,7 @@ export default function LocationMapSetupPanel({
{!hasZones ? ( {!hasZones ? (
<> <>
<button type="button" className="btn-primary" onClick={onCreateBlank} disabled={saving}> <button type="button" className="btn-primary" onClick={onCreateBlank} disabled={saving}>
Create Blank Map {createBlankLabel}
</button> </button>
<button type="button" className="btn-secondary" onClick={onCreateFromZones} disabled> <button type="button" className="btn-secondary" onClick={onCreateFromZones} disabled>
{createFromZonesLabel} {createFromZonesLabel}
@ -56,10 +60,10 @@ export default function LocationMapSetupPanel({
) : ( ) : (
<> <>
<button type="button" className="btn-primary" onClick={onCreateFromZones} disabled={saving}> <button type="button" className="btn-primary" onClick={onCreateFromZones} disabled={saving}>
{createFromZonesLabel} {createZonesButtonLabel}
</button> </button>
<button type="button" className="btn-secondary" onClick={onCreateBlank} disabled={saving}> <button type="button" className="btn-secondary" onClick={onCreateBlank} disabled={saving}>
Create Blank Map {createBlankLabel}
</button> </button>
</> </>
)} )}

View File

@ -3,6 +3,7 @@ export default function LocationMapToolbar({
editorTool, editorTool,
hasAnyMap, hasAnyMap,
canManage, canManage,
saving,
zoom, zoom,
setZoom, setZoom,
onFit, onFit,
@ -18,7 +19,7 @@ export default function LocationMapToolbar({
<button <button
type="button" type="button"
className={mode === "view" ? "active" : ""} className={mode === "view" ? "active" : ""}
disabled={!hasAnyMap} disabled={saving || !hasAnyMap}
onClick={onView} onClick={onView}
aria-pressed={mode === "view"} aria-pressed={mode === "view"}
> >
@ -28,7 +29,7 @@ export default function LocationMapToolbar({
<button <button
type="button" type="button"
className={mode === "edit" ? "active" : ""} className={mode === "edit" ? "active" : ""}
disabled={!hasAnyMap} disabled={saving || !hasAnyMap}
onClick={onEdit} onClick={onEdit}
aria-pressed={mode === "edit"} aria-pressed={mode === "edit"}
aria-label="Edit Draft" aria-label="Edit Draft"
@ -44,6 +45,7 @@ export default function LocationMapToolbar({
type="button" type="button"
className={editorTool === "pan" ? "active is-pan" : ""} className={editorTool === "pan" ? "active is-pan" : ""}
onClick={onPanMode} onClick={onPanMode}
disabled={saving}
aria-pressed={editorTool === "pan"} aria-pressed={editorTool === "pan"}
> >
Pan Mode Pan Mode
@ -52,6 +54,7 @@ export default function LocationMapToolbar({
type="button" type="button"
className={editorTool === "edit" ? "active is-edit" : ""} className={editorTool === "edit" ? "active is-edit" : ""}
onClick={onEditObjects} onClick={onEditObjects}
disabled={saving}
aria-pressed={editorTool === "edit"} aria-pressed={editorTool === "edit"}
> >
Edit Objects Edit Objects

View File

@ -48,6 +48,7 @@ export default function LocationMapManager() {
const [mapState, setMapState] = useState(null); const [mapState, setMapState] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [savingAction, setSavingAction] = useState(null);
const [mode, setMode] = useState("setup"); const [mode, setMode] = useState("setup");
const [editorTool, setEditorTool] = useState("pan"); const [editorTool, setEditorTool] = useState("pan");
const [previewDraft, setPreviewDraft] = useState(false); const [previewDraft, setPreviewDraft] = useState(false);
@ -189,9 +190,19 @@ export default function LocationMapManager() {
}); });
}; };
const beginSaving = (action) => {
setSavingAction(action);
setSaving(true);
};
const endSaving = () => {
setSaving(false);
setSavingAction(null);
};
const handleCreateBlank = async () => { const handleCreateBlank = async () => {
if (!activeHousehold?.id || !locationId) return; if (!activeHousehold?.id || !locationId) return;
setSaving(true); beginSaving("create-blank");
try { try {
const response = await createBlankLocationMap(activeHousehold.id, locationId, mapDraft); const response = await createBlankLocationMap(activeHousehold.id, locationId, mapDraft);
setMapState(response.data); setMapState(response.data);
@ -203,13 +214,13 @@ export default function LocationMapManager() {
} 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"));
} finally { } finally {
setSaving(false); endSaving();
} }
}; };
const handleCreateFromZones = async () => { const handleCreateFromZones = async () => {
if (!activeHousehold?.id || !locationId) return; if (!activeHousehold?.id || !locationId) return;
setSaving(true); beginSaving("create-zones");
try { try {
const response = await createLocationMapFromZones(activeHousehold.id, locationId, mapDraft); const response = await createLocationMapFromZones(activeHousehold.id, locationId, mapDraft);
setMapState(response.data); setMapState(response.data);
@ -221,13 +232,13 @@ export default function LocationMapManager() {
} 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"));
} finally { } finally {
setSaving(false); endSaving();
} }
}; };
const handleSaveDraft = async () => { const handleSaveDraft = async () => {
if (!activeHousehold?.id || !locationId) return; if (!activeHousehold?.id || !locationId) return;
setSaving(true); beginSaving("save");
try { try {
const response = await saveLocationMapDraft(activeHousehold.id, locationId, { const response = await saveLocationMapDraft(activeHousehold.id, locationId, {
map: mapDraft, map: mapDraft,
@ -241,13 +252,13 @@ export default function LocationMapManager() {
} 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"));
} finally { } finally {
setSaving(false); endSaving();
} }
}; };
const handlePublish = async () => { const handlePublish = async () => {
if (!activeHousehold?.id || !locationId) return; if (!activeHousehold?.id || !locationId) return;
setSaving(true); beginSaving("publish");
try { try {
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
await saveLocationMapDraft(activeHousehold.id, locationId, { await saveLocationMapDraft(activeHousehold.id, locationId, {
@ -271,7 +282,7 @@ export default function LocationMapManager() {
} catch (error) { } catch (error) {
toast.error("Publish failed", getApiErrorMessage(error, "Failed to publish map")); toast.error("Publish failed", getApiErrorMessage(error, "Failed to publish map"));
} finally { } finally {
setSaving(false); endSaving();
} }
}; };
@ -461,6 +472,7 @@ export default function LocationMapManager() {
editorTool={editorTool} editorTool={editorTool}
hasAnyMap={hasAnyMap} hasAnyMap={hasAnyMap}
canManage={canManage} canManage={canManage}
saving={saving}
zoom={zoom} zoom={zoom}
setZoom={setZoom} setZoom={setZoom}
onFit={handleFitMap} onFit={handleFitMap}
@ -477,6 +489,7 @@ export default function LocationMapManager() {
canManage={canManage} canManage={canManage}
zoneCount={mapState?.zones?.length || 0} zoneCount={mapState?.zones?.length || 0}
saving={saving} saving={saving}
savingAction={savingAction}
onContinue={() => setMode("edit")} onContinue={() => setMode("edit")}
onPreview={handlePreviewDraft} onPreview={handlePreviewDraft}
onPublish={handlePublish} onPublish={handlePublish}
@ -497,6 +510,7 @@ export default function LocationMapManager() {
username={username} username={username}
svgRef={svgRef} svgRef={svgRef}
dragState={dragState} dragState={dragState}
editingLocked={saving}
setDragState={setDragState} setDragState={setDragState}
setSelectedObjectKey={setSelectedObjectKey} setSelectedObjectKey={setSelectedObjectKey}
remember={remember} remember={remember}
@ -516,6 +530,7 @@ export default function LocationMapManager() {
history={history} history={history}
future={future} future={future}
saving={saving} saving={saving}
savingAction={savingAction}
hasUnsavedChanges={hasUnsavedChanges} hasUnsavedChanges={hasUnsavedChanges}
mapState={mapState} mapState={mapState}
onAddObject={handleAddObject} onAddObject={handleAddObject}

View File

@ -175,6 +175,7 @@
.location-map-tool-buttons button:disabled, .location-map-tool-buttons button:disabled,
.location-map-zoom-controls button:disabled, .location-map-zoom-controls button:disabled,
.location-map-editor-actions button:disabled, .location-map-editor-actions button:disabled,
.location-map-mobile-tool-switch button:disabled,
.location-map-setup-actions button:disabled { .location-map-setup-actions button:disabled {
opacity: 0.55; opacity: 0.55;
cursor: not-allowed; cursor: not-allowed;

View File

@ -596,6 +596,79 @@ test("admin selecting an object does not mark a draft dirty until it changes", a
await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled();
}); });
test("admin save progress locks draft controls", async ({ page }) => {
await mockMapShell(page);
let mapState = draftMapState([
{
id: 1361,
location_map_id: 900,
zone_id: 501,
zone_name: "Bakery",
type: "zone",
label: "Bakery",
x: 40,
y: 40,
width: 260,
height: 160,
rotation: 0,
locked: false,
visible: true,
sort_order: 1,
},
], true);
let releaseSave: () => void = () => {};
let saveStarted = false;
const saveGate = new Promise<void>((resolve) => {
releaseSave = resolve;
});
await page.route("**/households/1/locations/10/map", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mapState),
});
});
await page.route("**/households/1/locations/10/map/draft", async (route) => {
saveStarted = true;
const payload = await route.request().postDataJSON();
await saveGate;
const savedObjects = (payload.objects as Array<Record<string, unknown>>).map((object, index) => ({
...(mapState.draft_objects[index] || {}),
...object,
}));
mapState = draftMapState(savedObjects, true);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mapState),
});
});
await page.goto("/stores/100/locations/10/map");
await page.getByRole("button", { name: "Continue Editing" }).click();
await page.getByRole("button", { name: "Edit Objects" }).click();
await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
await page.getByRole("textbox", { name: "Label" }).fill("Saving Bakery");
await page.getByRole("button", { name: "Save Draft" }).click();
await expect.poll(() => saveStarted).toBe(true);
await expect(page.getByRole("button", { name: "Saving..." })).toBeDisabled();
await expect(page.getByRole("button", { name: "Publish" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Add Area" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Preview Draft" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Duplicate" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Delete" })).toBeDisabled();
await expect(page.getByRole("textbox", { name: "Label" })).toBeDisabled();
releaseSave();
await expect(page.locator(".location-map-status")).toHaveText("Draft");
await expect(page.getByRole("button", { name: "Save Draft" })).toBeDisabled();
});
test("admin publish saves pending map edits before publishing", async ({ page }) => { test("admin publish saves pending map edits before publishing", async ({ page }) => {
await mockMapShell(page); await mockMapShell(page);