fix: lock map actions while saving
This commit is contained in:
parent
c236efdd72
commit
fd22cc1fe8
@ -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" ? (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user