diff --git a/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandle.tsx b/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandle.tsx
index 9fa295a6d..36aa2d327 100644
--- a/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandle.tsx
+++ b/packages/apollo-react/src/canvas/components/ButtonHandle/ButtonHandle.tsx
@@ -292,7 +292,7 @@ function InwardHandleContent({
const labelElement = label ? (
= {}) {
+ const onActiveIndexChange = vi.fn();
+
+ render(
+
+ );
+
+ return { onActiveIndexChange };
+}
+
+describe('IterationNavigator', () => {
+ it.each([
+ ['zero', 0],
+ ['negative', -1],
+ ['NaN', Number.NaN],
+ ['Infinity', Number.POSITIVE_INFINITY],
+ ])('does not render for %s total', (_caseName, total) => {
+ renderNavigator({ total });
+
+ expect(screen.queryByTestId('loop-iteration-navigator')).not.toBeInTheDocument();
+ });
+
+ it('clamps activeIndex before displaying and computing callbacks', async () => {
+ const user = userEvent.setup();
+ const { onActiveIndexChange } = renderNavigator({ activeIndex: 99, total: 3 });
+
+ expect(screen.getByTestId('loop-iteration-label')).toHaveTextContent('3 / 3');
+ expect(screen.getByRole('button', { name: 'Next loop iteration' })).toBeDisabled();
+
+ await user.click(screen.getByRole('button', { name: 'Previous loop iteration' }));
+
+ expect(onActiveIndexChange).toHaveBeenCalledOnce();
+ expect(onActiveIndexChange).toHaveBeenCalledWith(1);
+ });
+
+ it('fires previous and next callbacks with the adjacent index', async () => {
+ const user = userEvent.setup();
+ const { onActiveIndexChange } = renderNavigator({ activeIndex: 1, total: 3 });
+
+ await user.click(screen.getByRole('button', { name: 'Previous loop iteration' }));
+ await user.click(screen.getByRole('button', { name: 'Next loop iteration' }));
+
+ expect(onActiveIndexChange).toHaveBeenNthCalledWith(1, 0);
+ expect(onActiveIndexChange).toHaveBeenNthCalledWith(2, 2);
+ });
+
+ it('disables previous and next at iteration boundaries', () => {
+ const { rerender } = render(
+
+ );
+
+ expect(screen.getByRole('button', { name: 'Previous loop iteration' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Next loop iteration' })).not.toBeDisabled();
+
+ rerender(
+
+ );
+
+ expect(screen.getByRole('button', { name: 'Previous loop iteration' })).not.toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Next loop iteration' })).toBeDisabled();
+ });
+
+ it('renders as label-only when onActiveIndexChange is omitted', () => {
+ renderNavigator({ onActiveIndexChange: undefined });
+
+ expect(screen.getByTestId('loop-iteration-label')).toHaveTextContent('2 / 3');
+ expect(screen.getByRole('button', { name: 'Previous loop iteration' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Next loop iteration' })).toBeDisabled();
+ });
+
+ it('disables navigation when disabled', () => {
+ renderNavigator({ disabled: true });
+
+ expect(screen.getByRole('button', { name: 'Previous loop iteration' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Next loop iteration' })).toBeDisabled();
+ });
+});
diff --git a/packages/apollo-react/src/canvas/components/LoopNode/IterationNavigator.tsx b/packages/apollo-react/src/canvas/components/LoopNode/IterationNavigator.tsx
new file mode 100644
index 000000000..b142d9977
--- /dev/null
+++ b/packages/apollo-react/src/canvas/components/LoopNode/IterationNavigator.tsx
@@ -0,0 +1,130 @@
+import { cn } from '@uipath/apollo-wind';
+import type { SyntheticEvent } from 'react';
+import { useCallback } from 'react';
+import { clamp } from '../../utils';
+import { CanvasIcon } from '../../utils/icon-registry';
+import type { LoopIterationState } from './LoopNode.types';
+
+interface IterationNavigatorProps {
+ iterationState: LoopIterationState;
+}
+
+function resolveState(iterationState: LoopIterationState): LoopIterationState | undefined {
+ if (!Number.isFinite(iterationState.total)) {
+ return undefined;
+ }
+
+ const total = Math.trunc(iterationState.total);
+
+ if (total <= 0) {
+ return undefined;
+ }
+
+ const rawActiveIndex = Number.isFinite(iterationState.activeIndex)
+ ? Math.trunc(iterationState.activeIndex)
+ : 0;
+
+ return {
+ ...iterationState,
+ total,
+ activeIndex: clamp(rawActiveIndex, 0, total - 1),
+ };
+}
+
+function stopCanvasControlEvent(event: SyntheticEvent) {
+ event.stopPropagation();
+}
+
+export function IterationNavigator({ iterationState }: IterationNavigatorProps) {
+ const resolvedState = resolveState(iterationState);
+
+ if (!resolvedState) {
+ return null;
+ }
+
+ return
;
+}
+
+function NavigatorContent({ iterationState }: { iterationState: LoopIterationState }) {
+ const { activeIndex, total, onActiveIndexChange, disabled, ariaLabel } = iterationState;
+ const canInteract = !disabled && typeof onActiveIndexChange === 'function';
+ const canGoPrevious = canInteract && activeIndex > 0;
+ const canGoNext = canInteract && activeIndex < total - 1;
+ const label = ariaLabel ?? 'Loop iteration';
+ const visibleIndex = activeIndex + 1;
+
+ const handlePrevious = useCallback(
+ (event: SyntheticEvent) => {
+ event.stopPropagation();
+
+ if (!canGoPrevious) return;
+ onActiveIndexChange?.(activeIndex - 1);
+ },
+ [activeIndex, canGoPrevious, onActiveIndexChange]
+ );
+
+ const handleNext = useCallback(
+ (event: SyntheticEvent) => {
+ event.stopPropagation();
+
+ if (!canGoNext) return;
+ onActiveIndexChange?.(activeIndex + 1);
+ },
+ [activeIndex, canGoNext, onActiveIndexChange]
+ );
+
+ return (
+
+ );
+}
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 fc3b5a104..3f653177e 100644
--- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.stories.tsx
+++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.stories.tsx
@@ -2,14 +2,21 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import {
type Edge,
type Node,
+ type NodeProps,
Panel,
Position,
useReactFlow,
} from '@uipath/apollo-react/canvas/xyflow/react';
-import { useCallback, useEffect, useMemo, useRef } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAddNodeOnConnectEnd, useCanvasEvent } from '../../hooks';
-import { createNode, useCanvasStory, withCanvasProviders } from '../../storybook-utils';
+import {
+ createNode,
+ StoryInfoPanel,
+ useCanvasStory,
+ withCanvasProviders,
+} from '../../storybook-utils';
import { DefaultCanvasTranslations } from '../../types';
+import { ElementStatusValues } from '../../types/execution';
import type { CanvasHandleActionEvent } from '../../utils';
import { removePreviewFromReactFlow } from '../../utils/createPreviewNode';
import { snapToGrid } from '../../utils/NodeUtils';
@@ -18,6 +25,7 @@ import { createAddNodePreview } from '../AddNodePanel/createAddNodePreview';
import { BaseCanvas } from '../BaseCanvas';
import type { BaseNodeData } from '../BaseNode/BaseNode.types';
import { CanvasPositionControls } from '../CanvasPositionControls';
+import { LoopNode } from './LoopNode';
import type { LoopNodeData } from './LoopNode.types';
const meta: Meta = {
@@ -100,13 +108,24 @@ interface AutoPreviewSource {
position?: Position;
}
+interface StoryInfo {
+ title: string;
+ description: string;
+}
+
interface LoopCanvasStoryProps {
initialNodes: Node[];
initialEdges: Edge[];
autoPreviewSource?: AutoPreviewSource;
+ storyInfo: StoryInfo;
}
-function LoopCanvasStory({ initialNodes, initialEdges, autoPreviewSource }: LoopCanvasStoryProps) {
+function LoopCanvasStory({
+ initialNodes,
+ initialEdges,
+ autoPreviewSource,
+ storyInfo,
+}: LoopCanvasStoryProps) {
const reactFlow = useReactFlow();
const handleAddNodeOnConnectEnd = useAddNodeOnConnectEnd();
const autoPreviewedRef = useRef(false);
@@ -183,6 +202,7 @@ function LoopCanvasStory({ initialNodes, initialEdges, autoPreviewSource }: Loop
+
);
}
@@ -248,7 +268,17 @@ function DefaultStory() {
[]
);
- return
;
+ return (
+
+ );
}
function NestedOuterOutputInsertStory() {
@@ -354,6 +384,11 @@ function NestedOuterOutputInsertStory() {
handleId: STORY_LOOP_SUCCESS_HANDLE_ID,
position: Position.Right,
}}
+ storyInfo={{
+ title: 'Nested Loop Insert',
+ description:
+ 'Nested loop scenario showing insertion from an inner loop output into an existing outer-loop path.',
+ }}
/>
);
}
@@ -441,10 +476,170 @@ function NestedOuterOutputAppendStory() {
handleId: STORY_LOOP_SUCCESS_HANDLE_ID,
position: Position.Right,
}}
+ storyInfo={{
+ title: 'Nested Loop Append',
+ description:
+ 'Nested loop scenario showing append behavior from an inner loop output while preserving parent loop containment.',
+ }}
+ />
+ );
+}
+
+type LoopExecutionNodeData = LoopNodeData & {
+ initialIndex: number;
+ total: number;
+ interactive?: boolean;
+};
+
+const LOOP_EXECUTION_SIZE = { width: 520, height: 280 };
+const LOOP_EXECUTION_GRID = {
+ startX: 80,
+ startY: 80,
+ gapX: 640,
+ gapY: 360,
+} as const;
+
+const LOOP_EXECUTION_CASES: {
+ id: string;
+ label: string;
+ status: ElementStatusValues;
+ initialIndex: number;
+ total: number;
+ parallel?: boolean;
+ interactive?: boolean;
+}[] = [
+ {
+ id: 'loop-completed',
+ label: 'Completed loop',
+ status: ElementStatusValues.Completed,
+ initialIndex: 2,
+ total: 3,
+ },
+ {
+ id: 'loop-running',
+ label: 'Running loop',
+ status: ElementStatusValues.InProgress,
+ initialIndex: 1,
+ total: 3,
+ },
+ {
+ id: 'loop-paused',
+ label: 'Paused loop',
+ status: ElementStatusValues.Paused,
+ initialIndex: 1,
+ total: 4,
+ },
+ {
+ id: 'loop-failed',
+ label: 'Failed loop',
+ status: ElementStatusValues.Failed,
+ initialIndex: 0,
+ total: 3,
+ },
+ {
+ id: 'loop-cancelled',
+ label: 'Cancelled',
+ status: ElementStatusValues.Cancelled,
+ initialIndex: 2,
+ total: 5,
+ },
+ {
+ id: 'loop-parallel',
+ label: 'Parallel loop',
+ status: ElementStatusValues.Completed,
+ initialIndex: 2,
+ total: 3,
+ parallel: true,
+ },
+ {
+ id: 'loop-label-only',
+ label: 'Label only',
+ status: ElementStatusValues.Completed,
+ initialIndex: 1,
+ total: 3,
+ interactive: false,
+ },
+ {
+ id: 'loop-clamped',
+ label: 'Clamped active index',
+ status: ElementStatusValues.Completed,
+ initialIndex: 99,
+ total: 3,
+ },
+];
+
+const LOOP_EXECUTION_STATUS = new Map(LOOP_EXECUTION_CASES.map(({ id, status }) => [id, status]));
+
+function createExecutionStateGrid(): Node
[] {
+ return LOOP_EXECUTION_CASES.map(
+ ({ id, label, initialIndex, total, parallel, interactive }, index) => {
+ const colIndex = index % 2;
+ const rowIndex = Math.floor(index / 2);
+
+ return {
+ id,
+ type: LOOP_TYPE,
+ position: {
+ x: LOOP_EXECUTION_GRID.startX + colIndex * LOOP_EXECUTION_GRID.gapX,
+ y: LOOP_EXECUTION_GRID.startY + rowIndex * LOOP_EXECUTION_GRID.gapY,
+ },
+ data: {
+ display: { label, shape: 'container' },
+ parallel,
+ initialIndex,
+ total,
+ interactive,
+ },
+ style: LOOP_EXECUTION_SIZE,
+ };
+ }
+ );
+}
+
+function LoopExecutionCanvasNode(props: NodeProps>) {
+ const { data } = props;
+ const [activeIndex, setActiveIndex] = useState(data.initialIndex);
+
+ useEffect(() => {
+ setActiveIndex(data.initialIndex);
+ }, [data.initialIndex]);
+
+ return (
+
);
}
+const LOOP_EXECUTION_NODE_TYPES = {
+ [LOOP_TYPE]: LoopExecutionCanvasNode,
+};
+
+function ExecutionStatesStory() {
+ const initialNodes = useMemo(() => createExecutionStateGrid(), []);
+ const { canvasProps } = useCanvasStory({
+ initialNodes,
+ additionalNodeTypes: LOOP_EXECUTION_NODE_TYPES,
+ });
+
+ return (
+
+
+
+
+
+
+ );
+}
+
export const Default: Story = {
render: () => ,
};
@@ -456,3 +651,18 @@ export const NestedOuterOutputInsert: Story = {
export const NestedOuterOutputAppend: Story = {
render: () => ,
};
+
+export const ExecutionStates: Story = {
+ decorators: [
+ withCanvasProviders({
+ executionState: {
+ getNodeExecutionState: (nodeId: string) => LOOP_EXECUTION_STATUS.get(nodeId),
+ getEdgeExecutionState: () => undefined,
+ },
+ validationState: {
+ getElementValidationState: () => undefined,
+ },
+ }),
+ ],
+ render: () => ,
+};
diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx
index 44cf34cac..a11c2e383 100644
--- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx
+++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx
@@ -35,8 +35,9 @@ import { MissingManifestNode } from '../BaseNode/BaseNodeMissingManifest';
import type { HandleActionEvent } from '../ButtonHandle';
import { ButtonHandles } from '../ButtonHandle';
import { NodeToolbar } from '../Toolbar';
+import { IterationNavigator } from './IterationNavigator';
import { type ContainerHandleGroup, resolveContainerHandleGroups } from './LoopNode.helpers';
-import type { LoopNodeProps } from './LoopNode.types';
+import type { LoopIterationState, LoopNodeProps } from './LoopNode.types';
const DEFAULT_LOOP_ICON = 'repeat';
const DEFAULT_LOOP_TITLE = 'Loop';
@@ -182,6 +183,7 @@ function LoopNodeComponent(props: LoopNodeProps) {
adornments: adornmentsProp,
executionStatusOverride,
suggestionType: suggestionTypeProp,
+ iterationState: iterationStateProp,
} = props;
const nodeTypeRegistry = useOptionalNodeTypeRegistry();
const [isHovered, setIsHovered] = useState(false);
@@ -341,7 +343,7 @@ function LoopNodeComponent(props: LoopNodeProps) {
className={cn(
'group/loop-shell relative box-border flex h-full w-full flex-col overflow-visible rounded-[20px] border bg-transparent',
'transition-[border-color,box-shadow,opacity] shadow-(--canvas-node-shadow-rest)',
- 'border-border-subtle',
+ 'border-border',
getStatusBorder(suggestionType ?? validationState?.validationStatus ?? executionStatus),
isHovered && 'shadow-(--canvas-node-shadow-hover) border-border-hover',
selected && 'outline outline-2 outline-foreground-accent-muted',
@@ -366,7 +368,13 @@ function LoopNodeComponent(props: LoopNodeProps) {
{showResizeControls ? (
) : null}
-
+
{showEmptyStateButton ? (
@@ -433,19 +443,26 @@ function Header({
return (
{iconContent}
{titleContent}
-
-
-
+
+ {iterationState ? : null}
+
+
+
+
+ {isParallel ? 'Parallel' : 'Sequential'}
- {isParallel ? 'Parallel' : 'Sequential'}
-
+
);
}
@@ -476,7 +493,8 @@ function BodyFrame({ isEmpty, isLoading }: { isEmpty?: boolean; isLoading?: bool
data-testid="loop-body-frame"
data-empty={isEmpty ? 'true' : 'false'}
className={cn(
- 'relative m-2.5 flex flex-1 rounded-xl border-[1.5px] border-dashed border-border-subtle bg-transparent',
+ 'relative m-2.5 flex flex-1 rounded-xl border-[1.5px] border-dashed border-border bg-transparent',
+ 'shadow-[0_0_0_10px_var(--surface-overlay)]',
'pointer-events-none'
)}
>
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 4026d230d..7ed89bb17 100644
--- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.types.ts
+++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.types.ts
@@ -12,11 +12,20 @@ export interface LoopNodeResizeSize {
height: number;
}
+export interface LoopIterationState {
+ activeIndex: number;
+ total: number;
+ onActiveIndexChange?: (nextIndex: number) => void;
+ disabled?: boolean;
+ ariaLabel?: string;
+}
+
export interface LoopNodeConfig {
toolbarConfig?: NodeToolbarConfig | null;
adornments?: NodeAdornments;
executionStatusOverride?: ElementStatusValues;
suggestionType?: SuggestionType;
+ iterationState?: LoopIterationState;
}
export interface LoopNodeProps extends NodeProps>, LoopNodeConfig {