Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -318,6 +322,18 @@ function LoopCanvasStory({
);
}

type BoundaryStateLoopNodeData = LoopNodeData & {
boundaryState?: LoopNodeBoundaryState;
};

function BoundaryStateLoopCanvasNode(props: NodeProps<Node<BoundaryStateLoopNodeData>>) {
return <LoopNode {...props} boundaryState={props.data.boundaryState} />;
}

const BOUNDARY_STATE_NODE_TYPES = {
[LOOP_TYPE]: BoundaryStateLoopCanvasNode,
};

function DefaultStory() {
const initialNodes = useMemo<Node[]>(
() => [
Expand Down Expand Up @@ -392,6 +408,64 @@ function DefaultStory() {
);
}

function BoundaryStatesStory() {
const initialNodes = useMemo<Node<BoundaryStateLoopNodeData>[]>(
() => [
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 (
<BaseCanvas {...canvasProps} mode="design">
<Panel position="bottom-right">
<CanvasPositionControls translations={DefaultCanvasTranslations} />
</Panel>
<StoryInfoPanel
title="Boundary States"
description="Default, drop-target, and invalid loop boundary styling."
/>
</BaseCanvas>
);
}

function NestedOuterOutputInsertStory() {
const initialNodes = useMemo<Node[]>(
() => [
Expand Down Expand Up @@ -787,6 +861,11 @@ export const Default: Story = {
render: () => <DefaultStory />,
};

export const BoundaryStates: Story = {
name: 'Boundary States',
render: () => <BoundaryStatesStory />,
};

export const NestedOuterOutputInsert: Story = {
render: () => <NestedOuterOutputInsertStory />,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ function LoopNodeComponent(props: LoopNodeProps) {
onResizeEnd,
toolbarConfig: toolbarConfigProp,
adornments: adornmentsProp,
boundaryState = 'default',
executionStatusOverride,
suggestionType: suggestionTypeProp,
iterationPillState: iterationPillStateProp,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand All @@ -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={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ export interface LoopNodeExecutionCountState {
iterationStatuses?: Map<number, ElementStatusValues>;
}

export type LoopNodeBoundaryState = 'default' | 'drop-target' | 'invalid';

export interface LoopNodeConfig {
toolbarConfig?: NodeToolbarConfig | null;
adornments?: NodeAdornments;
boundaryState?: LoopNodeBoundaryState;
executionStatusOverride?: ElementStatusValues;
suggestionType?: SuggestionType;
iterationPillState?: LoopNodeExecutionCountState;
Expand Down
41 changes: 41 additions & 0 deletions packages/apollo-react/src/canvas/utils/container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getContainerResizeMinimums,
getContainerSafeArea,
getNodeDimensions,
isRectInsideContainerSafeArea,
placeContainerNode,
} from './container';

Expand All @@ -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',
Expand Down
71 changes: 59 additions & 12 deletions packages/apollo-react/src/canvas/utils/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,24 @@ export interface ContainerSafeArea {
};
}

export interface ContainerSafeAreaBuffer {
left: number;
right: number;
top: number;
bottom: number;
}

export interface ContainerSafeAreaOptions {
buffer?: Partial<ContainerSafeAreaBuffer>;
}

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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -207,21 +239,19 @@ export function getNodeDimensions(
*/
export function getContainerSafeArea(
containerNode: Pick<Node, 'width' | 'height' | 'measured' | 'style'>,
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 {
Expand All @@ -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<Node, 'width' | 'height' | 'measured' | 'style'>
): boolean {
const safeArea = getContainerSafeArea(containerNode, undefined, {
buffer: CONTAINER_BOUNDARY_SAFE_AREA_BUFFER,
});
Comment on lines +266 to +273

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
Expand Down
Loading