fix: lock map actions while saving

This commit is contained in:
Nico 2026-06-03 16:56:32 -07:00
parent c236efdd72
commit fd22cc1fe8
7 changed files with 136 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -175,6 +175,7 @@
.location-map-tool-buttons button:disabled,
.location-map-zoom-controls button:disabled,
.location-map-editor-actions button:disabled,
.location-map-mobile-tool-switch button:disabled,
.location-map-setup-actions button:disabled {
opacity: 0.55;
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();
});
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 }) => {
await mockMapShell(page);