diff --git a/frontend/src/components/maps/LocationMapCanvas.jsx b/frontend/src/components/maps/LocationMapCanvas.jsx index f54b8ed..d44a568 100644 --- a/frontend/src/components/maps/LocationMapCanvas.jsx +++ b/frontend/src/components/maps/LocationMapCanvas.jsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { + clampObjectToMap, clientPointToMap, getObjectKey, itemMatchesMapFilters, @@ -25,6 +26,18 @@ function getSelectedObjectScrollKey(object, selectedObjectKey, zoom) { ].join(":"); } +function clampResizedObjectToMap(object, mapSize) { + const x = Math.max(0, Math.min(object.x, mapSize.width - 40)); + const y = Math.max(0, Math.min(object.y, mapSize.height - 40)); + return { + ...object, + x, + y, + width: Math.max(40, Math.min(object.width, mapSize.width - x)), + height: Math.max(40, Math.min(object.height, mapSize.height - y)), + }; +} + export default function LocationMapCanvas({ mode, objects, @@ -194,17 +207,17 @@ export default function LocationMapCanvas({ currentObjects.map((object) => { if (getObjectKey(object) !== dragState.key) return object; if (dragState.type === "resize") { - return { + return clampResizedObjectToMap({ ...object, width: Math.max(40, dragState.startObject.width + deltaX), height: Math.max(40, dragState.startObject.height + deltaY), - }; + }, mapSize); } - return { + return clampObjectToMap({ ...object, x: dragState.startObject.x + deltaX, y: dragState.startObject.y + deltaY, - }; + }, mapSize); }) ); }; @@ -257,11 +270,11 @@ export default function LocationMapCanvas({ updateObjects((currentObjects) => currentObjects.map((candidate) => getObjectKey(candidate) === getObjectKey(object) - ? { + ? clampObjectToMap({ ...candidate, x: candidate.x + deltaX, y: candidate.y + deltaY, - } + }, mapSize) : candidate ) ); diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index 0d4ee63..c244d0c 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -2087,6 +2087,113 @@ test("admin nudges selected map areas with arrow keys", async ({ page }) => { await expect(bakeryArea).toHaveAttribute("y", "65"); }); +test("admin cannot nudge map areas outside the canvas", async ({ page }) => { + await mockMapShell(page); + + const mapState = draftMapState([ + { + id: 1353, + location_map_id: 900, + zone_id: 501, + zone_name: "Bakery", + type: "zone", + label: "Bakery", + x: 740, + y: 540, + width: 260, + height: 160, + rotation: 0, + locked: false, + visible: true, + sort_order: 1, + }, + ], true); + + await page.route("**/households/1/locations/10/map", async (route) => { + 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(); + const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); + await bakeryArea.focus(); + await page.keyboard.press("Enter"); + await expect(bakeryArea).toHaveAttribute("x", "740"); + await expect(bakeryArea).toHaveAttribute("y", "540"); + + await page.keyboard.press("Shift+ArrowRight"); + await page.keyboard.press("Shift+ArrowDown"); + + await expect(bakeryArea).toHaveAttribute("x", "740"); + await expect(bakeryArea).toHaveAttribute("y", "540"); + await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); +}); + +test("admin cannot resize map areas beyond the canvas", async ({ page }) => { + await mockMapShell(page); + + const mapState = draftMapState([ + { + id: 1354, + location_map_id: 900, + zone_id: 501, + zone_name: "Bakery", + type: "zone", + label: "Bakery", + x: 820, + y: 580, + width: 180, + height: 120, + rotation: 0, + locked: false, + visible: true, + sort_order: 1, + }, + ], true); + + await page.route("**/households/1/locations/10/map", async (route) => { + 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(); + const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); + await bakeryArea.click(); + await expect(bakeryArea).toHaveAttribute("x", "820"); + await expect(bakeryArea).toHaveAttribute("y", "580"); + await expect(bakeryArea).toHaveAttribute("width", "180"); + await expect(bakeryArea).toHaveAttribute("height", "120"); + + const resizeHandle = page.locator(".location-map-resize-handle"); + await expect(resizeHandle).toBeVisible(); + const resizeBox = await resizeHandle.boundingBox(); + expect(resizeBox).not.toBeNull(); + if (!resizeBox) throw new Error("Resize handle was not measurable"); + + await page.mouse.move(resizeBox.x + resizeBox.width / 2, resizeBox.y + resizeBox.height / 2); + await page.mouse.down(); + await page.mouse.move(resizeBox.x + resizeBox.width / 2 + 160, resizeBox.y + resizeBox.height / 2 + 160, { + steps: 8, + }); + await page.mouse.up(); + + await expect(bakeryArea).toHaveAttribute("x", "820"); + await expect(bakeryArea).toHaveAttribute("y", "580"); + await expect(bakeryArea).toHaveAttribute("width", "180"); + await expect(bakeryArea).toHaveAttribute("height", "120"); + await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); +}); + test("admin selected map area uses compact editable fields", async ({ page }) => { await mockMapShell(page);