diff --git a/frontend/src/components/maps/LocationMapCanvas.jsx b/frontend/src/components/maps/LocationMapCanvas.jsx index 1a04b44..57be1b4 100644 --- a/frontend/src/components/maps/LocationMapCanvas.jsx +++ b/frontend/src/components/maps/LocationMapCanvas.jsx @@ -44,6 +44,16 @@ function panScrollElement(scrollElement, deltaX, deltaY) { ); } +function shiftMapObject(object, shiftX, shiftY) { + if (!shiftX && !shiftY) return object; + + return { + ...object, + x: Number(object.x || 0) + shiftX, + y: Number(object.y || 0) + shiftY, + }; +} + function getSelectedObjectScrollKey(object, selectedObjectKey, zoom) { return [ selectedObjectKey, @@ -78,8 +88,8 @@ function getDraggedObjectCandidate(object, dragState, deltaX, deltaY) { return { ...object, - x: Math.max(0, dragState.startObject.x + deltaX), - y: Math.max(0, dragState.startObject.y + deltaY), + x: dragState.startObject.x + deltaX, + y: dragState.startObject.y + deltaY, }; } @@ -135,6 +145,8 @@ export default function LocationMapCanvas({ : ""; const scrollRef = useRef(null); const panDragRef = useRef(null); + const pendingMapShiftScrollRef = useRef({ x: 0, y: 0 }); + const scheduledMapShiftScrollRef = useRef(null); const objectDragHistoryCapturedRef = useRef(false); const suppressPanClickRef = useRef(false); const autoScrolledObjectViewRef = useRef(null); @@ -173,6 +185,55 @@ export default function LocationMapCanvas({ return { assignedCounts, visibleItemsByZone }; }, [filters, mapItems, username]); + useEffect(() => () => { + if (scheduledMapShiftScrollRef.current && typeof window !== "undefined") { + window.clearTimeout(scheduledMapShiftScrollRef.current); + } + }, []); + + const flushQueuedMapShiftScroll = () => { + const queuedShift = pendingMapShiftScrollRef.current; + if (!queuedShift.x && !queuedShift.y) return; + + pendingMapShiftScrollRef.current = { x: 0, y: 0 }; + const scrollElement = scrollRef.current; + panScrollElement(scrollElement, queuedShift.x * zoom, queuedShift.y * zoom); + + if (!scrollElement || typeof window === "undefined") return; + + const targetLeft = scrollElement.scrollLeft; + const targetTop = scrollElement.scrollTop; + const restoreScrollTarget = () => { + const currentScrollElement = scrollRef.current; + if (!currentScrollElement) return; + + currentScrollElement.scrollLeft = Math.max(currentScrollElement.scrollLeft, targetLeft); + currentScrollElement.scrollTop = Math.max(currentScrollElement.scrollTop, targetTop); + }; + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + restoreScrollTarget(); + window.setTimeout(restoreScrollTarget, 80); + }); + }); + }; + + const queueMapShiftScroll = (shiftX, shiftY) => { + if (!shiftX && !shiftY) return; + + pendingMapShiftScrollRef.current = { + x: pendingMapShiftScrollRef.current.x + shiftX, + y: pendingMapShiftScrollRef.current.y + shiftY, + }; + + if (typeof window === "undefined" || scheduledMapShiftScrollRef.current) return; + + scheduledMapShiftScrollRef.current = window.setTimeout(() => { + scheduledMapShiftScrollRef.current = null; + window.requestAnimationFrame(flushQueuedMapShiftScroll); + }, 0); + }; + useEffect(() => { if (!selectedObjectKey) { autoScrolledObjectViewRef.current = null; @@ -266,10 +327,20 @@ export default function LocationMapCanvas({ const draggedObject = objects.find((object) => getObjectKey(object) === dragState.key); if (!draggedObject) return; const nextDraggedObjectCandidate = getDraggedObjectCandidate(draggedObject, dragState, deltaX, deltaY); - const nextMapSize = expandMapToFitObject?.(nextDraggedObjectCandidate) || mapSize; + const expansion = expandMapToFitObject?.(nextDraggedObjectCandidate) || { + mapSize, + shiftX: 0, + shiftY: 0, + }; + const nextMapSize = expansion.mapSize; + const shiftedDraggedObjectCandidate = shiftMapObject( + nextDraggedObjectCandidate, + expansion.shiftX, + expansion.shiftY + ); const nextDraggedObject = dragState.type === "resize" - ? clampResizedObjectToMap(nextDraggedObjectCandidate, nextMapSize) - : clampObjectToMap(nextDraggedObjectCandidate, nextMapSize); + ? clampResizedObjectToMap(shiftedDraggedObjectCandidate, nextMapSize) + : clampObjectToMap(shiftedDraggedObjectCandidate, nextMapSize); if (!hasObjectBoundsChanged(draggedObject, nextDraggedObject)) return; if (!objectDragHistoryCapturedRef.current) { @@ -277,13 +348,32 @@ export default function LocationMapCanvas({ objectDragHistoryCapturedRef.current = true; } + if (expansion.shiftX || expansion.shiftY) { + queueMapShiftScroll(expansion.shiftX, expansion.shiftY); + setDragState((currentDragState) => { + if (!currentDragState || currentDragState.key !== dragState.key) return currentDragState; + + return { + ...currentDragState, + startObject: shiftMapObject(currentDragState.startObject, expansion.shiftX, expansion.shiftY), + startPoint: { + x: currentDragState.startPoint.x + expansion.shiftX, + y: currentDragState.startPoint.y + expansion.shiftY, + }, + }; + }); + } + updateObjects( (currentObjects) => currentObjects.map((object) => { - if (getObjectKey(object) !== dragState.key) return object; + if (getObjectKey(object) !== dragState.key) { + return shiftMapObject(object, expansion.shiftX, expansion.shiftY); + } const candidate = getDraggedObjectCandidate(object, dragState, deltaX, deltaY); + const shiftedCandidate = shiftMapObject(candidate, expansion.shiftX, expansion.shiftY); return dragState.type === "resize" - ? clampResizedObjectToMap(candidate, nextMapSize) - : clampObjectToMap(candidate, nextMapSize); + ? clampResizedObjectToMap(shiftedCandidate, nextMapSize) + : clampObjectToMap(shiftedCandidate, nextMapSize); }), nextMapSize ); @@ -333,21 +423,30 @@ export default function LocationMapCanvas({ const deltaY = event.key === "ArrowUp" ? -nudgeAmount : event.key === "ArrowDown" ? nudgeAmount : 0; const nextObjectCandidate = { ...object, - x: Math.max(0, object.x + deltaX), - y: Math.max(0, object.y + deltaY), + x: object.x + deltaX, + y: object.y + deltaY, }; - const nextMapSize = expandMapToFitObject?.(nextObjectCandidate) || mapSize; - const nextObject = clampObjectToMap(nextObjectCandidate, nextMapSize); + const expansion = expandMapToFitObject?.(nextObjectCandidate) || { + mapSize, + shiftX: 0, + shiftY: 0, + }; + const nextMapSize = expansion.mapSize; + const nextObject = clampObjectToMap( + shiftMapObject(nextObjectCandidate, expansion.shiftX, expansion.shiftY), + nextMapSize + ); setSelectedObjectKey(getObjectKey(object)); if (!hasObjectBoundsChanged(object, nextObject)) return; remember(undefined, getObjectKey(object)); + queueMapShiftScroll(expansion.shiftX, expansion.shiftY); updateObjects( (currentObjects) => currentObjects.map((candidate) => getObjectKey(candidate) === getObjectKey(object) ? nextObject - : candidate + : shiftMapObject(candidate, expansion.shiftX, expansion.shiftY) ), nextMapSize ); diff --git a/frontend/src/hooks/useLocationMapObjectActions.js b/frontend/src/hooks/useLocationMapObjectActions.js index 70476f6..aaed22a 100644 --- a/frontend/src/hooks/useLocationMapObjectActions.js +++ b/frontend/src/hooks/useLocationMapObjectActions.js @@ -52,20 +52,24 @@ export default function useLocationMapObjectActions({ }, [mapSize, setHasUnsavedChanges, setObjects]); const expandMapToFitObject = useCallback((object) => { + const shiftX = object.x < 0 ? Math.ceil(Math.abs(object.x) + MAP_EDGE_GROW_PADDING) : 0; + const shiftY = object.y < 0 ? Math.ceil(Math.abs(object.y) + MAP_EDGE_GROW_PADDING) : 0; + const shiftedObject = { + ...object, + x: Number(object.x || 0) + shiftX, + y: Number(object.y || 0) + shiftY, + }; const nextWidth = Math.max( - mapSize.width, - Math.ceil(Number(object.x || 0) + Number(object.width || 0) + MAP_EDGE_GROW_PADDING) + mapSize.width + shiftX, + Math.ceil(shiftedObject.x + Number(object.width || 0) + MAP_EDGE_GROW_PADDING) ); const nextHeight = Math.max( - mapSize.height, - Math.ceil(Number(object.y || 0) + Number(object.height || 0) + MAP_EDGE_GROW_PADDING) + mapSize.height + shiftY, + Math.ceil(shiftedObject.y + Number(object.height || 0) + MAP_EDGE_GROW_PADDING) ); - const nextMapSize = { - width: nextWidth, - height: nextHeight, - }; + const nextMapSize = { width: nextWidth, height: nextHeight }; - if (nextWidth > mapSize.width || nextHeight > mapSize.height) { + if (nextWidth > mapSize.width || nextHeight > mapSize.height || shiftX > 0 || shiftY > 0) { setMapDraft((currentDraft) => ({ ...currentDraft, width: Math.max(Number(currentDraft.width || 0), nextWidth), @@ -74,7 +78,7 @@ export default function useLocationMapObjectActions({ setHasUnsavedChanges(true); } - return nextMapSize; + return { mapSize: nextMapSize, shiftX, shiftY }; }, [mapSize.height, mapSize.width, setHasUnsavedChanges, setMapDraft]); const handleAddObject = useCallback(() => { diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index 786dbea..241c201 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -215,17 +215,18 @@ async function expectResizeHandleWithinMapScroll(page: Page) { const scroll = page.locator(".location-map-scroll"); const resizeHandle = page.locator(".location-map-resize-handle"); await expect(resizeHandle).toBeVisible(); - const scrollBox = await scroll.boundingBox(); - const handleBox = await resizeHandle.boundingBox(); - expect(scrollBox).not.toBeNull(); - expect(handleBox).not.toBeNull(); - if (!scrollBox || !handleBox) { - throw new Error("Selected resize handle was not measurable"); - } - expect(handleBox.x).toBeGreaterThanOrEqual(scrollBox.x); - expect(handleBox.y).toBeGreaterThanOrEqual(scrollBox.y); - expect(handleBox.x + handleBox.width).toBeLessThanOrEqual(scrollBox.x + scrollBox.width + 1); - expect(handleBox.y + handleBox.height).toBeLessThanOrEqual(scrollBox.y + scrollBox.height + 1); + await expect.poll(async () => { + const scrollBox = await scroll.boundingBox(); + const handleBox = await resizeHandle.boundingBox(); + if (!scrollBox || !handleBox) return false; + + return ( + handleBox.x >= scrollBox.x && + handleBox.y >= scrollBox.y && + handleBox.x + handleBox.width <= scrollBox.x + scrollBox.width + 1 && + handleBox.y + handleBox.height <= scrollBox.y + scrollBox.height + 1 + ); + }).toBe(true); } test("admin creates a map from zones, saves a draft, and publishes it", async ({ page }) => { @@ -2104,10 +2105,11 @@ test("admin can drag map areas beyond the current right edge", async ({ page }) sort_order: 1, }, ], true); + const initialMapWidth = 1400; const mapState = { ...baseState, - map: { ...baseState.map, width: 700, height: 420 }, - draft_map: { ...baseState.draft_map, width: 700, height: 420 }, + map: { ...baseState.map, width: initialMapWidth, height: 420 }, + draft_map: { ...baseState.draft_map, width: initialMapWidth, height: 420 }, }; let savedPayload: { map?: { width?: number; height?: number }; objects?: Array> } | null = null; @@ -2150,7 +2152,7 @@ test("admin can drag map areas beyond the current right edge", async ({ page }) await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0); await page.getByRole("button", { name: "Save Draft" }).click(); - await expect.poll(() => savedPayload?.map?.width || 0).toBeGreaterThan(700); + await expect.poll(() => savedPayload?.map?.width || 0).toBeGreaterThan(initialMapWidth); expect(savedPayload?.objects?.[0]).toEqual( expect.objectContaining({ label: "Bakery", @@ -2160,6 +2162,98 @@ test("admin can drag map areas beyond the current right edge", async ({ page }) expect(Number(savedPayload?.objects?.[0]?.x)).toBeGreaterThan(420); }); +test("admin can drag map areas beyond the current left edge", async ({ page }) => { + await page.setViewportSize({ width: 398, height: 617 }); + await mockMapShell(page); + + const baseState = draftMapState([ + { + id: 1322, + location_map_id: 900, + zone_id: 501, + zone_name: "Bakery", + type: "zone", + label: "Bakery", + x: 80, + y: 80, + width: 180, + height: 140, + rotation: 0, + locked: false, + visible: true, + sort_order: 1, + }, + { + id: 1323, + location_map_id: 900, + zone_id: 502, + zone_name: "Frozen Foods", + type: "zone", + label: "Frozen Foods", + x: 320, + y: 80, + width: 180, + height: 140, + rotation: 0, + locked: false, + visible: true, + sort_order: 2, + }, + ], true); + const leftEdgeInitialMapWidth = 1400; + const mapState = { + ...baseState, + map: { ...baseState.map, width: leftEdgeInitialMapWidth, height: 420 }, + draft_map: { ...baseState.draft_map, width: leftEdgeInitialMapWidth, height: 420 }, + }; + let savedPayload: { map?: { width?: number; height?: number }; objects?: Array> } | null = null; + + 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) => { + savedPayload = await route.request().postDataJSON(); + const savedState = draftMapState(savedPayload?.objects || [], true); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ...savedState, + map: { ...savedState.map, width: savedPayload?.map?.width, height: savedPayload?.map?.height }, + draft_map: { ...savedState.draft_map, width: savedPayload?.map?.width, height: savedPayload?.map?.height }, + }), + }); + }); + + await page.goto("/stores/100/locations/10/map"); + await page.getByRole("button", { name: "Continue Editing" }).click(); + const scroll = page.locator(".location-map-scroll"); + const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); + await bakeryArea.click(); + + const areaBox = await bakeryArea.boundingBox(); + expect(areaBox).not.toBeNull(); + if (!areaBox) throw new Error("Bakery map area was not measurable"); + await page.mouse.move(areaBox.x + 8, areaBox.y + 8); + await page.mouse.down(); + await page.mouse.move(2, areaBox.y + 8, { steps: 12 }); + await page.mouse.up(); + + await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0); + await page.getByRole("button", { name: "Save Draft" }).click(); + + await expect.poll(() => savedPayload?.map?.width || 0).toBeGreaterThan(leftEdgeInitialMapWidth); + const savedFrozenArea = savedPayload?.objects?.find((object) => object.label === "Frozen Foods"); + expect(savedFrozenArea).toEqual(expect.objectContaining({ label: "Frozen Foods" })); + expect(Number(savedFrozenArea?.x)).toBeGreaterThan(320); + expect(savedPayload?.objects?.every((object) => Number(object.x) >= 0)).toBe(true); +}); + test("admin selected overlapping map area renders above other areas", async ({ page }) => { await mockMapShell(page); @@ -2405,7 +2499,7 @@ 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 }) => { +test("admin can nudge map areas beyond the current canvas", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ @@ -2447,14 +2541,14 @@ test("admin cannot nudge map areas outside the canvas", async ({ page }) => { 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("Draft"); - await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled(); + await expect(bakeryArea).toHaveAttribute("x", "765"); + await expect(bakeryArea).toHaveAttribute("y", "565"); + await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); + await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled(); }); -test("admin cannot resize map areas beyond the canvas", async ({ page }) => { +test("admin can resize map areas beyond the current canvas", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ @@ -2509,11 +2603,11 @@ test("admin cannot resize map areas beyond the canvas", async ({ page }) => { 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("Draft"); - await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled(); + await expect.poll(async () => Number(await bakeryArea.getAttribute("width"))).toBeGreaterThan(180); + await expect.poll(async () => Number(await bakeryArea.getAttribute("height"))).toBeGreaterThan(120); + await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); + await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled(); }); test("admin selected map area uses compact editable fields", async ({ page }) => {