From a8e88be08315d36a8294d57e0687635e8d434d5f Mon Sep 17 00:00:00 2001 From: Sreedhar Avvari Date: Sun, 14 Jun 2026 20:42:12 +0530 Subject: [PATCH] feat(apollo-react): add loop boundary states and safe area validation [MST-11096] --- .../components/LoopNode/LoopNode.stories.tsx | 81 ++++++++++++++++++- .../components/LoopNode/LoopNode.test.tsx | 38 +++++++++ .../canvas/components/LoopNode/LoopNode.tsx | 6 +- .../components/LoopNode/LoopNode.types.ts | 3 + .../src/canvas/utils/container.test.ts | 41 ++++++++++ .../src/canvas/utils/container.ts | 71 +++++++++++++--- 6 files changed, 225 insertions(+), 15 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.stories.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.stories.tsx index d9c5f3ccb..c41ac468a 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.stories.tsx @@ -28,7 +28,11 @@ import { BaseCanvas } from '../BaseCanvas'; import type { BaseNodeData } from '../BaseNode/BaseNode.types'; import { CanvasPositionControls } from '../CanvasPositionControls'; import { LoopNode } from './LoopNode'; -import type { LoopNodeData, LoopNodeExecutionCountState } from './LoopNode.types'; +import type { + LoopNodeBoundaryState, + LoopNodeData, + LoopNodeExecutionCountState, +} from './LoopNode.types'; import { LoopNodeExecutionCount } from './LoopNodeExecutionCount'; const meta: Meta = { @@ -318,6 +322,18 @@ function LoopCanvasStory({ ); } +type BoundaryStateLoopNodeData = LoopNodeData & { + boundaryState?: LoopNodeBoundaryState; +}; + +function BoundaryStateLoopCanvasNode(props: NodeProps>) { + return ; +} + +const BOUNDARY_STATE_NODE_TYPES = { + [LOOP_TYPE]: BoundaryStateLoopCanvasNode, +}; + function DefaultStory() { const initialNodes = useMemo( () => [ @@ -392,6 +408,64 @@ function DefaultStory() { ); } +function BoundaryStatesStory() { + const initialNodes = useMemo[]>( + () => [ + createLoopContainerNode( + 'loop-boundary-default', + { x: 80, y: 160 }, + { width: 352, height: 240 }, + { + data: { + boundaryState: 'default', + display: { label: 'Default' }, + }, + } + ), + createLoopContainerNode( + 'loop-drop-target-boundary', + { x: 496, y: 160 }, + { width: 352, height: 240 }, + { + data: { + boundaryState: 'drop-target', + display: { label: 'Drop target' }, + }, + } + ), + createLoopContainerNode( + 'loop-invalid-boundary', + { x: 912, y: 160 }, + { width: 352, height: 240 }, + { + data: { + boundaryState: 'invalid', + display: { label: 'Invalid' }, + }, + } + ), + ], + [] + ); + + const { canvasProps } = useCanvasStory({ + initialNodes, + additionalNodeTypes: BOUNDARY_STATE_NODE_TYPES, + }); + + return ( + + + + + + + ); +} + function NestedOuterOutputInsertStory() { const initialNodes = useMemo( () => [ @@ -787,6 +861,11 @@ export const Default: Story = { render: () => , }; +export const BoundaryStates: Story = { + name: 'Boundary States', + render: () => , +}; + export const NestedOuterOutputInsert: Story = { render: () => , }; diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.test.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.test.tsx index 3cac30854..4282733f4 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.test.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.test.tsx @@ -258,6 +258,44 @@ describe('LoopNode status border hover treatment', () => { }); }); +describe('LoopNode boundary state', () => { + it('renders the loop shell in the default boundary state', () => { + renderLoopNode(); + + const container = getLoopContainer(); + const bodyFrame = screen.getByTestId('loop-body-frame'); + expect(container).toHaveAttribute('data-boundary-state', 'default'); + expect(container).not.toHaveClass('outline-error'); + expect(container).not.toHaveClass('outline-brand'); + expect(bodyFrame).toHaveClass('border-border'); + expect(bodyFrame).not.toHaveClass('border-error'); + }); + + it('renders the loop shell with error styling when the boundary state is invalid', () => { + renderLoopNode({ boundaryState: 'invalid' }); + + const container = getLoopContainer(); + const bodyFrame = screen.getByTestId('loop-body-frame'); + expect(container).toHaveAttribute('data-boundary-state', 'invalid'); + expect(container).toHaveClass('outline-error'); + expect(container).not.toHaveClass('outline-brand'); + expect(bodyFrame).toHaveClass('border-border'); + expect(bodyFrame).not.toHaveClass('border-error'); + }); + + it('renders the loop shell with brand styling when the boundary state is drop target', () => { + renderLoopNode({ boundaryState: 'drop-target' }); + + const container = getLoopContainer(); + const bodyFrame = screen.getByTestId('loop-body-frame'); + expect(container).toHaveAttribute('data-boundary-state', 'drop-target'); + expect(container).toHaveClass('outline-brand'); + expect(container).not.toHaveClass('outline-error'); + expect(bodyFrame).toHaveClass('border-border'); + expect(bodyFrame).not.toHaveClass('border-error'); + }); +}); + describe('LoopNode execution count', () => { it('renders the execution count pill when iterationPillState is provided', () => { const iterationPillState: LoopNodeExecutionCountState = { diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx index aab2bcc79..7b9ec1208 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx @@ -189,6 +189,7 @@ function LoopNodeComponent(props: LoopNodeProps) { onResizeEnd, toolbarConfig: toolbarConfigProp, adornments: adornmentsProp, + boundaryState = 'default', executionStatusOverride, suggestionType: suggestionTypeProp, iterationPillState: iterationPillStateProp, @@ -254,7 +255,6 @@ function LoopNodeComponent(props: LoopNodeProps) { id: 'loop-node.add-node', message: 'Add node to loop', }); - const isDropTarget = resolvedData.isDropTarget === true; const containerWidth = width || DEFAULT_CONTAINER_WIDTH; const containerHeight = height || DEFAULT_CONTAINER_HEIGHT; const resizeControlsMounted = isDesignMode && !dragging; @@ -371,6 +371,7 @@ function LoopNodeComponent(props: LoopNodeProps) { data-selected={selected ? 'true' : 'false'} data-execution-status={executionStatus} data-interaction-state={interactionState} + data-boundary-state={boundaryState} data-suggestion-type={suggestionType} data-validation-status={validationState?.validationStatus} aria-busy={resolvedData.loading || undefined} @@ -382,7 +383,8 @@ function LoopNodeComponent(props: LoopNodeProps) { isHovered && 'shadow-(--canvas-node-shadow-hover)', isHovered && !hasStatusBorder && 'border-border-hover', selected && 'outline outline-2 outline-foreground-accent-muted', - isDropTarget && 'bg-surface-hover outline outline-2 outline-brand', + boundaryState === 'drop-target' && 'outline outline-2 outline-brand', + boundaryState === 'invalid' && 'outline outline-2 outline-error', interactionState === 'drag' && 'cursor-grabbing shadow-(--canvas-node-shadow-lifted)' )} style={{ diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.types.ts b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.types.ts index f20d1cfec..943988ed8 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.types.ts +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.types.ts @@ -22,9 +22,12 @@ export interface LoopNodeExecutionCountState { iterationStatuses?: Map; } +export type LoopNodeBoundaryState = 'default' | 'drop-target' | 'invalid'; + export interface LoopNodeConfig { toolbarConfig?: NodeToolbarConfig | null; adornments?: NodeAdornments; + boundaryState?: LoopNodeBoundaryState; executionStatusOverride?: ElementStatusValues; suggestionType?: SuggestionType; iterationPillState?: LoopNodeExecutionCountState; diff --git a/packages/apollo-react/src/canvas/utils/container.test.ts b/packages/apollo-react/src/canvas/utils/container.test.ts index ca406a8ec..87171b2eb 100644 --- a/packages/apollo-react/src/canvas/utils/container.test.ts +++ b/packages/apollo-react/src/canvas/utils/container.test.ts @@ -12,6 +12,7 @@ import { getContainerResizeMinimums, getContainerSafeArea, getNodeDimensions, + isRectInsideContainerSafeArea, placeContainerNode, } from './container'; @@ -38,6 +39,46 @@ describe('container sizing', () => { }); }); + it('detects whether a child rect is fully inside the container boundary safe area', () => { + const containerNode: Node = { + id: 'loop-1', + type: 'loop', + position: { x: 0, y: 0 }, + style: { width: 704, height: 368 }, + data: {}, + }; + + expect( + isRectInsideContainerSafeArea({ x: 48, y: 80, width: 96, height: 96 }, containerNode) + ).toBe(true); + expect( + isRectInsideContainerSafeArea({ x: 47, y: 80, width: 96, height: 96 }, containerNode) + ).toBe(false); + expect( + isRectInsideContainerSafeArea({ x: 48, y: 79, width: 96, height: 96 }, containerNode) + ).toBe(false); + expect( + isRectInsideContainerSafeArea({ x: 561, y: 80, width: 96, height: 96 }, containerNode) + ).toBe(false); + expect( + isRectInsideContainerSafeArea({ x: 48, y: 225, width: 96, height: 96 }, containerNode) + ).toBe(false); + }); + + it('uses measured dimensions when validating a child rect against a container safe area', () => { + const containerNode: Node = { + id: 'loop-1', + type: 'loop', + position: { x: 0, y: 0 }, + measured: { width: 704, height: 368 }, + data: {}, + }; + + expect( + isRectInsideContainerSafeArea({ x: 144, y: 96, width: 416, height: 224 }, containerNode) + ).toBe(true); + }); + it('computes side-specific resize minimums that keep children inside the body', () => { const containerNode: Node = { id: 'loop-1', diff --git a/packages/apollo-react/src/canvas/utils/container.ts b/packages/apollo-react/src/canvas/utils/container.ts index ae2ecdf53..d2044d086 100644 --- a/packages/apollo-react/src/canvas/utils/container.ts +++ b/packages/apollo-react/src/canvas/utils/container.ts @@ -62,6 +62,24 @@ export interface ContainerSafeArea { }; } +export interface ContainerSafeAreaBuffer { + left: number; + right: number; + top: number; + bottom: number; +} + +export interface ContainerSafeAreaOptions { + buffer?: Partial; +} + +export interface RectLike { + x: number; + y: number; + width: number; + height: number; +} + /** Records a single container resize caused by fitting children. */ export interface ContainerSizeChange { containerId: string; @@ -161,6 +179,20 @@ const CONTAINER_BODY_PADDING_PX = GRID_SPACING * 2; const CONTAINER_INNER_HANDLE_RAIL_WIDTH_PX = GRID_SPACING * 5; const CONTAINER_CHILD_SAFE_GAP_PX = GRID_SPACING; const DEFAULT_CONTAINER_HEADER_HEIGHT_PX = 40; +const DEFAULT_CONTAINER_SAFE_AREA_BUFFER: ContainerSafeAreaBuffer = { + left: + CONTAINER_BODY_PADDING_PX + CONTAINER_INNER_HANDLE_RAIL_WIDTH_PX + CONTAINER_CHILD_SAFE_GAP_PX, + right: + CONTAINER_BODY_PADDING_PX + CONTAINER_INNER_HANDLE_RAIL_WIDTH_PX + CONTAINER_CHILD_SAFE_GAP_PX, + top: CONTAINER_BODY_PADDING_PX, + bottom: CONTAINER_BODY_PADDING_PX, +}; +const CONTAINER_BOUNDARY_SAFE_AREA_BUFFER: ContainerSafeAreaBuffer = { + left: CONTAINER_BODY_PADDING_PX, + right: CONTAINER_BODY_PADDING_PX, + top: GRID_SPACING, + bottom: CONTAINER_BODY_PADDING_PX, +}; /** Horizontal gap maintained between nodes in a container sequence. */ export const CONTAINER_SEQUENCE_GAP_PX = GRID_SPACING * 3; @@ -207,21 +239,19 @@ export function getNodeDimensions( */ export function getContainerSafeArea( containerNode: Pick, - fallback: NodeDimensions = { width: DEFAULT_CONTAINER_WIDTH, height: DEFAULT_CONTAINER_HEIGHT } + fallback: NodeDimensions = { width: DEFAULT_CONTAINER_WIDTH, height: DEFAULT_CONTAINER_HEIGHT }, + options: ContainerSafeAreaOptions = {} ): ContainerSafeArea { const size = getNodeDimensions(containerNode, fallback); - const horizontalPadding = snapUpToGrid( - CONTAINER_FRAME_INSET_PX + - CONTAINER_BODY_PADDING_PX + - CONTAINER_INNER_HANDLE_RAIL_WIDTH_PX + - CONTAINER_CHILD_SAFE_GAP_PX - ); - const verticalPadding = snapUpToGrid(CONTAINER_FRAME_INSET_PX + CONTAINER_BODY_PADDING_PX); + const buffer = { + ...DEFAULT_CONTAINER_SAFE_AREA_BUFFER, + ...options.buffer, + }; const padding = { - left: horizontalPadding, - right: horizontalPadding, - top: snapUpToGrid(DEFAULT_CONTAINER_HEADER_HEIGHT_PX + verticalPadding), - bottom: verticalPadding, + left: snapUpToGrid(CONTAINER_FRAME_INSET_PX + buffer.left), + right: snapUpToGrid(CONTAINER_FRAME_INSET_PX + buffer.right), + top: snapUpToGrid(DEFAULT_CONTAINER_HEADER_HEIGHT_PX + CONTAINER_FRAME_INSET_PX + buffer.top), + bottom: snapUpToGrid(CONTAINER_FRAME_INSET_PX + buffer.bottom), }; return { @@ -233,6 +263,23 @@ export function getContainerSafeArea( }; } +/** Returns whether a local child rect is fully inside the container boundary safe area. */ +export function isRectInsideContainerSafeArea( + rect: RectLike, + containerNode: Pick +): boolean { + const safeArea = getContainerSafeArea(containerNode, undefined, { + buffer: CONTAINER_BOUNDARY_SAFE_AREA_BUFFER, + }); + + return ( + rect.x >= safeArea.x && + rect.y >= safeArea.y && + rect.x + rect.width <= safeArea.x + safeArea.width && + rect.y + rect.height <= safeArea.y + safeArea.height + ); +} + /** * Default fit rules for loop/container nodes. `minWidth`/`minHeight` are the * intrinsic default footprint (what a fresh empty container renders at), so