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 ( +
+ + {label}: {visibleIndex} of {total} + + + + {visibleIndex} / {total} + + +
+ ); +} 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 {