diff --git a/.gitignore b/.gitignore index 51aba828f..dfd56f70d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,12 @@ !.claude/skills/ !.claude/settings.json +# Code visualization tools +.codeviz/ + +# Uninitialized app scaffolds (no source code yet) +apps/apollo-chat/ + # Planning (short-term, task-related documents) plan/* !plan/README.md @@ -24,6 +30,7 @@ storybook-static/ # Compiled locale files (generated by lingui compile) **/locales/*.ts +**/locales/*.js # Playwright MCP .playwright-mcp 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 3f653177e..75da973cc 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.stories.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.stories.tsx @@ -25,8 +25,11 @@ import { createAddNodePreview } from '../AddNodePanel/createAddNodePreview'; import { BaseCanvas } from '../BaseCanvas'; import type { BaseNodeData } from '../BaseNode/BaseNode.types'; import { CanvasPositionControls } from '../CanvasPositionControls'; +import { CanvasIcon } from '../../utils/icon-registry'; +import { cn } from '@uipath/apollo-wind'; +import { LoopNodeExecutionCount } from './LoopNodeExecutionCount'; import { LoopNode } from './LoopNode'; -import type { LoopNodeData } from './LoopNode.types'; +import type { LoopNodeExecutionCountState, LoopNodeData } from './LoopNode.types'; const meta: Meta = { title: 'Components/LoopNode', @@ -666,3 +669,712 @@ export const ExecutionStates: Story = { ], render: () => , }; + +// ============================================================================ +// Execution Count — LoopNodeExecutionCount doc page +// ============================================================================ + +type LoopCountNodeData = LoopNodeData & { + initialIndex: number; + initialIsAll?: boolean; + total: number; + interactive?: boolean; + iterationStatuses?: Map; + status?: ElementStatusValues; +}; + +const EXECUTION_COUNT_STATUS = new Map([ + ['ec-all', ElementStatusValues.InProgress], + ['ec-full', ElementStatusValues.InProgress], + ['ec-compact', ElementStatusValues.Completed], + ['ec-minimal', ElementStatusValues.Failed], +]); + +const EXECUTION_COUNT_ITERATION_STATUSES = new Map>([ + [ + 'ec-all', + new Map([ + [0, 'Completed'], + [1, 'Completed'], + [2, 'Completed'], + [3, 'InProgress'], + [4, 'Failed'], + ]), + ], + [ + 'ec-full', + new Map([ + [0, 'Completed'], + [1, 'Completed'], + [2, 'InProgress'], + ]), + ], + [ + 'ec-compact', + new Map([ + [0, 'Completed'], + [1, 'Completed'], + [2, 'Completed'], + ]), + ], + [ + 'ec-minimal', + new Map([ + [0, 'Completed'], + [1, 'Failed'], + ]), + ], +]); + +function createExecutionCountNodes(): Node[] { + return [ + { + id: 'ec-all', + type: LOOP_TYPE, + position: { x: 80, y: 80 }, + data: { + display: { label: 'For Each Order', shape: 'container' }, + initialIndex: 3, + initialIsAll: true, + total: 5, + iterationStatuses: EXECUTION_COUNT_ITERATION_STATUSES.get('ec-all'), + status: ElementStatusValues.InProgress, + }, + style: { width: 600, height: 240 }, + }, + { + id: 'ec-full', + type: LOOP_TYPE, + position: { x: 80, y: 368 }, + data: { + display: { label: 'For Each Region', shape: 'container' }, + initialIndex: 2, + total: 5, + iterationStatuses: EXECUTION_COUNT_ITERATION_STATUSES.get('ec-full'), + status: ElementStatusValues.InProgress, + }, + style: { width: 600, height: 240 }, + }, + { + id: 'ec-compact', + type: LOOP_TYPE, + position: { x: 80, y: 656 }, + data: { + display: { label: 'For Each City', shape: 'container' }, + initialIndex: 2, + total: 3, + iterationStatuses: EXECUTION_COUNT_ITERATION_STATUSES.get('ec-compact'), + status: ElementStatusValues.Completed, + }, + style: { width: 300, height: 240 }, + }, + { + id: 'ec-minimal', + type: LOOP_TYPE, + position: { x: 80, y: 944 }, + data: { + display: { label: 'For Each Item', shape: 'container' }, + initialIndex: 1, + total: 8, + iterationStatuses: EXECUTION_COUNT_ITERATION_STATUSES.get('ec-minimal'), + status: ElementStatusValues.Failed, + }, + style: { width: 200, height: 240 }, + }, + ]; +} + +function LoopCountCanvasNode(props: NodeProps>) { + const { data } = props; + const [activeIndex, setActiveIndex] = useState( + Math.max(0, Math.min(data.total - 1, data.initialIndex)) + ); + const [isAll, setIsAll] = useState(data.initialIsAll ?? false); + + useEffect(() => { + setActiveIndex(Math.max(0, Math.min(data.total - 1, data.initialIndex))); + setIsAll(data.initialIsAll ?? false); + }, [data.initialIndex, data.initialIsAll, data.total]); + + const iterationPillState: LoopNodeExecutionCountState = { + activeIndex, + total: data.total, + onActiveIndexChange: + data.interactive === false + ? undefined + : (i) => { + setIsAll(false); + setActiveIndex(i); + }, + isAll, + onAllChange: setIsAll, + iterationStatuses: data.iterationStatuses, + overallStatus: data.status, + }; + + return ; +} + +const LOOP_COUNT_NODE_TYPES = { + [LOOP_TYPE]: LoopCountCanvasNode, +}; + +function ExecutionCountCanvas() { + const initialNodes = useMemo(() => createExecutionCountNodes(), []); + const { canvasProps } = useCanvasStory({ + initialNodes, + additionalNodeTypes: LOOP_COUNT_NODE_TYPES, + }); + + return ( + + + + + + ); +} + +function ExecutionCountPreviewButton({ + expanded, + onExpand, + onClose, +}: { + expanded: boolean; + onExpand: () => void; + onClose: () => void; +}) { + return expanded ? ( + + ) : ( + + ); +} + +const tierRows = [ + { + width: '≥ 400 px', + tier: 'full' as const, + controls: 'All toggle · ‹ prev · k/N fraction (click-to-type) · next › · jump-to-failed ⊕', + badgeClass: 'bg-emerald-500/10 text-emerald-600', + }, + { + width: '260 – 399 px', + tier: 'compact' as const, + controls: 'All toggle · k/N fraction (click-to-type) · jump-to-failed ⊕', + badgeClass: 'bg-amber-500/10 text-amber-600', + }, + { + width: '< 260 px', + tier: 'minimal' as const, + controls: 'Count chip only — read-only', + badgeClass: 'bg-sky-500/10 text-sky-600', + }, +] as const; + +const ANATOMY_ITERATION_STATUSES = new Map([ + [0, 'Completed'], + [1, 'Completed'], + [2, 'InProgress'], + [3, 'Failed'], +]); +const ANATOMY_TOTAL = 5; + +function CollapsibleSection({ + title, + open, + onToggle, + children, +}: { + title: string; + open: boolean; + onToggle: () => void; + children: React.ReactNode; +}) { + return ( +
+ + {open &&
{children}
} +
+ ); +} + +function ExecutionCountPage({ globalTheme }: { globalTheme: string }) { + const [expanded, setExpanded] = useState(false); + const [anatomyOpen, setAnatomyOpen] = useState(false); + const [headerLayoutOpen, setHeaderLayoutOpen] = useState(false); + const [howToUseOpen, setHowToUseOpen] = useState(false); + const allOpen = anatomyOpen && headerLayoutOpen && howToUseOpen; + const toggleAll = () => { + const next = !allOpen; + setAnatomyOpen(next); + setHeaderLayoutOpen(next); + setHowToUseOpen(next); + }; + const [defaultAnatIndex, setDefaultAnatIndex] = useState(3); + const [defaultAnatIsAll, setDefaultAnatIsAll] = useState(true); + const [anatIndex, setAnatIndex] = useState(2); + const [anatIsAll, setAnatIsAll] = useState(false); + const [headerIndex, setHeaderIndex] = useState(2); + const [headerIsAll, setHeaderIsAll] = useState(false); + + return ( +
+ {/* ── Header ── */} +
+

Execution Count

+

+ + LoopNodeExecutionCount + {' '} + is a unified segmented pill that lets users navigate loop iterations at runtime. An{' '} + All toggle on the left switches between + aggregate and per-iteration views. The right segment shows the current fraction{' '} + k / N with + prev/next arrows, a click-to-type jump shortcut, and an optional crosshair button that + jumps directly to the first failed iteration. The component adapts to three size tiers + based on the available header width. +

+
+
+ + {/* ── Preview ── */} +
+
+

Preview

+

+ Three loop nodes side-by-side: wide (full tier), medium (compact tier), and narrow + (minimal tier). Interact with the pills directly on the canvas. +

+
+
+
+ {!expanded && } + setExpanded(true)} + onClose={() => setExpanded(false)} + /> +
+
+
+ + {/* Expanded overlay */} + {expanded && ( +
setExpanded(false)} + > +
e.stopPropagation()} + > + + setExpanded(true)} + onClose={() => setExpanded(false)} + /> +
+
+ )} + + {/* ── Sections ── */} +
+ {/* Expand / Collapse all */} +
+ +
+ + setAnatomyOpen((o) => !o)} + > +

Default

+

+ By default, the pill opens in the All view, + showing the aggregate execution summary across all iterations. Click{' '} + All to toggle into individual iteration + navigation. +

+
+ { + setDefaultAnatIsAll(false); + setDefaultAnatIndex(i); + }, + isAll: defaultAnatIsAll, + onAllChange: setDefaultAnatIsAll, + iterationStatuses: ANATOMY_ITERATION_STATUSES, + }} + /> +
+ + {/* Tier cards */} +

Responsive

+

+ The pill adapts to three responsive tiers based on the loop node's rendered width. As + nodes are resized or deeply nested, controls are progressively removed so the pill never + overflows the header. +

+
+ {tierRows.map((row) => ( +
+
+ + {row.tier} + + {row.width} +
+
+ { + setAnatIsAll(false); + setAnatIndex(i); + }, + isAll: anatIsAll, + onAllChange: setAnatIsAll, + iterationStatuses: ANATOMY_ITERATION_STATUSES, + }} + /> +
+

{row.controls}

+
+ ))} +
+ + {/* Spec table */} +
+ + + + + + + + + + {tierRows.map((row, i) => ( + + + + + + ))} + +
+ Node width + + size tier + + Controls visible +
+ {row.width} + + + {row.tier} + + {row.controls}
+
+ + {/* Count Range */} +
+

Count Range

+

+ The pill supports iteration counts from 1{' '} + to 999. The fraction scales naturally as + digit width increases — no truncation or overflow at any value. +

+
+
+ {[ + { active: 0, total: 1, label: 'Minimum' }, + { active: 4, total: 10, label: '' }, + { active: 49, total: 100, label: '' }, + { active: 99, total: 500, label: '' }, + { active: 998, total: 999, label: 'Maximum' }, + ].map(({ active, total, label }) => ( +
+ {}, + }} + /> + + {label || `${active + 1} / ${total}`} + +
+ ))} +
+
+
+
+ + setHeaderLayoutOpen((o) => !o)} + > +

+ The loop node header is divided into two regions. The right region always renders in + this fixed order: execution count pill first, then the sequential/parallel chip. +

+ + {/* Visual mockup */} +
+
+
+
+ + + + + For Each Region + +
+
+ { + setHeaderIsAll(false); + setHeaderIndex(i); + }, + isAll: headerIsAll, + onAllChange: setHeaderIsAll, + iterationStatuses: ANATOMY_ITERATION_STATUSES, + }} + /> + + + + + Sequential + +
+
+
+
+
+ + A + + Icon · Title — truncates on narrow nodes +
+
+
+ + B + + Execution Count pill +
+
+ + C + + Sequential / Parallel chip +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Region + + Content + Rule
+ Left + Icon · Title + Title truncates with ellipsis as the node narrows +
+ Right — 1st + + + LoopNodeExecutionCount + + + Adapts to full / compact / minimal tier based on{' '} + + nodeWidth + + ; always right-aligned, never overlaid +
+ Right — 2nd + + Sequential / Parallel chip + + Fixed width; always the rightmost element in the header +
+
+

+ Pass{' '} + + iterationPillState + {' '} + directly to{' '} + LoopNode. + The component computes the size tier from its measured{' '} + width{' '} + automatically — no absolute positioning or overlay wrappers needed. +

+
+ + setHowToUseOpen((o) => !o)} + > +

+ Build a{' '} + + LoopNodeExecutionCountState + {' '} + object from your runtime data and pass it as{' '} + + iterationPillState + {' '} + to LoopNode + . The size tier is derived automatically from the node's measured width — no overlay or + absolute positioning needed. +

+
+            {`import { LoopNode } from '@uipath/apollo-react/canvas';
+import type { LoopNodeExecutionCountState } from '@uipath/apollo-react/canvas';
+
+function MyLoopCanvasNode(props: NodeProps>) {
+  const [activeIndex, setActiveIndex] = useState(0);
+  const [isAll, setIsAll] = useState(false);
+
+  const iterationPillState: LoopNodeExecutionCountState = {
+    activeIndex,
+    total: props.data.total,
+    onActiveIndexChange: (i) => { setIsAll(false); setActiveIndex(i); },
+    isAll,
+    onAllChange: setIsAll,
+    iterationStatuses: props.data.iterationStatuses,
+    overallStatus: props.data.status,
+  };
+
+  // LoopNode computes full / compact / minimal tier from its measured width automatically
+  return ;
+}`}
+          
+
+
+
+ ); +} + +export const ExecutionCount: Story = { + name: 'Execution Count', + decorators: [ + withCanvasProviders({ + executionState: { + getNodeExecutionState: (nodeId: string) => EXECUTION_COUNT_STATUS.get(nodeId), + getEdgeExecutionState: () => undefined, + }, + validationState: { + getElementValidationState: () => undefined, + }, + }), + ], + render: (_, { globals }) => , +}; diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx index 18096b498..8eac34903 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.tsx @@ -37,8 +37,13 @@ import type { HandleActionEvent } from '../ButtonHandle'; import { ButtonHandles } from '../ButtonHandle'; import { NodeToolbar } from '../Toolbar'; import { IterationNavigator } from './IterationNavigator'; +import { LoopNodeExecutionCount } from './LoopNodeExecutionCount'; import { type ContainerHandleGroup, resolveContainerHandleGroups } from './LoopNode.helpers'; -import type { LoopIterationState, LoopNodeProps } from './LoopNode.types'; +import type { + LoopNodeExecutionCountState, + LoopIterationState, + LoopNodeProps, +} from './LoopNode.types'; const DEFAULT_LOOP_ICON = 'repeat'; const DEFAULT_LOOP_TITLE = 'Loop'; @@ -188,6 +193,7 @@ function LoopNodeComponent(props: LoopNodeProps) { executionStatusOverride, suggestionType: suggestionTypeProp, iterationState: iterationStateProp, + iterationPillState: iterationPillStateProp, } = props; const nodeTypeRegistry = useOptionalNodeTypeRegistry(); const [isHovered, setIsHovered] = useState(false); @@ -384,6 +390,8 @@ function LoopNodeComponent(props: LoopNodeProps) { loading={isLoading} isParallel={isParallel} iterationState={iterationStateProp} + iterationPillState={iterationPillStateProp} + nodeWidth={width} hasTopLeftAdornment={hasTopLeftAdornment} hasTopRightAdornment={hasTopRightAdornment} /> @@ -432,6 +440,8 @@ function Header({ loading, isParallel, iterationState, + iterationPillState, + nodeWidth, hasTopLeftAdornment, hasTopRightAdornment, }: { @@ -440,6 +450,8 @@ function Header({ loading: boolean; isParallel: boolean; iterationState?: LoopIterationState; + iterationPillState?: LoopNodeExecutionCountState; + nodeWidth: number; hasTopLeftAdornment: boolean; hasTopRightAdornment: boolean; }) { @@ -480,7 +492,14 @@ function Header({ {titleContent}
- {iterationState ? : null} + {iterationPillState ? ( + = 400 ? 'full' : nodeWidth >= 260 ? 'compact' : 'minimal'} + /> + ) : iterationState ? ( + + ) : null} 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 7ed89bb17..e8ca9f8d7 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.types.ts +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNode.types.ts @@ -20,12 +20,24 @@ export interface LoopIterationState { ariaLabel?: string; } +export interface LoopNodeExecutionCountState { + activeIndex: number; + total: number; + onActiveIndexChange?: (nextIndex: number) => void; + disabled?: boolean; + isAll: boolean; + onAllChange: (isAll: boolean) => void; + iterationStatuses?: Map; + overallStatus?: ElementStatusValues; +} + export interface LoopNodeConfig { toolbarConfig?: NodeToolbarConfig | null; adornments?: NodeAdornments; executionStatusOverride?: ElementStatusValues; suggestionType?: SuggestionType; iterationState?: LoopIterationState; + iterationPillState?: LoopNodeExecutionCountState; } export interface LoopNodeProps extends NodeProps>, LoopNodeConfig { diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeExecutionCount.test.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeExecutionCount.test.tsx new file mode 100644 index 000000000..cfc37db9a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeExecutionCount.test.tsx @@ -0,0 +1,362 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { getIterationStatusColor, LoopNodeExecutionCount } from './LoopNodeExecutionCount'; +import type { LoopNodeExecutionCountState } from './LoopNode.types'; + +vi.mock('../../utils/icon-registry', () => ({ + CanvasIcon: ({ icon }: { icon: string }) => , +})); + +function buildState( + overrides: Partial = {} +): LoopNodeExecutionCountState { + return { + activeIndex: 1, + total: 5, + onActiveIndexChange: vi.fn(), + disabled: false, + isAll: false, + onAllChange: vi.fn(), + ...overrides, + }; +} + +function renderPill( + stateOverrides: Partial = {}, + size: 'full' | 'compact' | 'minimal' = 'full' +) { + const state = buildState(stateOverrides); + render(); + return { + onActiveIndexChange: state.onActiveIndexChange as ReturnType, + onAllChange: state.onAllChange as ReturnType, + }; +} + +// ─── getIterationStatusColor ────────────────────────────────────────────────── + +describe('getIterationStatusColor', () => { + it.each([ + ['Completed', '#22c55e'], + ['Failed', '#ef4444'], + ['InProgress', '#f59e0b'], + ['Paused', '#a855f7'], + ['Cancelled', '#94a3b8'], + [undefined, 'currentColor'], + ['Unknown', 'currentColor'], + ])('returns correct color for status %s', (status, expected) => { + expect(getIterationStatusColor(status as string | undefined)).toBe(expected); + }); +}); + +// ─── Full tier — prev / next ────────────────────────────────────────────────── + +describe('LoopNodeExecutionCount (full tier)', () => { + it('displays the visible index and total', () => { + renderPill({ activeIndex: 2, total: 7 }); + + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + }); + + it('fires onActiveIndexChange with the previous index on prev click', async () => { + const user = userEvent.setup(); + const { onActiveIndexChange } = renderPill({ activeIndex: 2, total: 5 }); + + await user.click(screen.getByRole('button', { name: 'Previous iteration' })); + + expect(onActiveIndexChange).toHaveBeenCalledOnce(); + expect(onActiveIndexChange).toHaveBeenCalledWith(1); + }); + + it('fires onActiveIndexChange with the next index on next click', async () => { + const user = userEvent.setup(); + const { onActiveIndexChange } = renderPill({ activeIndex: 2, total: 5 }); + + await user.click(screen.getByRole('button', { name: 'Next iteration' })); + + expect(onActiveIndexChange).toHaveBeenCalledOnce(); + expect(onActiveIndexChange).toHaveBeenCalledWith(3); + }); + + it('disables prev at the first iteration', () => { + renderPill({ activeIndex: 0, total: 5 }); + + expect(screen.getByRole('button', { name: 'Previous iteration' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next iteration' })).not.toBeDisabled(); + }); + + it('disables next at the last iteration', () => { + renderPill({ activeIndex: 4, total: 5 }); + + expect(screen.getByRole('button', { name: 'Previous iteration' })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next iteration' })).toBeDisabled(); + }); + + it('disables both nav buttons when disabled prop is true', () => { + renderPill({ activeIndex: 2, total: 5, disabled: true }); + + expect(screen.getByRole('button', { name: 'Previous iteration' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next iteration' })).toBeDisabled(); + }); + + it('disables both nav buttons when onActiveIndexChange is omitted', () => { + renderPill({ activeIndex: 2, total: 5, onActiveIndexChange: undefined }); + + expect(screen.getByRole('button', { name: 'Previous iteration' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next iteration' })).toBeDisabled(); + }); +}); + +// ─── All toggle ─────────────────────────────────────────────────────────────── + +describe('All toggle', () => { + it('calls onAllChange(true) when isAll is false and All is clicked', async () => { + const user = userEvent.setup(); + const { onAllChange } = renderPill({ isAll: false }); + + await user.click(screen.getByRole('button', { name: 'Show aggregate across all iterations' })); + + expect(onAllChange).toHaveBeenCalledOnce(); + expect(onAllChange).toHaveBeenCalledWith(true); + }); + + it('calls onAllChange(false) when isAll is true and All is clicked', async () => { + const user = userEvent.setup(); + const { onAllChange } = renderPill({ isAll: true }); + + await user.click(screen.getByRole('button', { name: 'Show aggregate across all iterations' })); + + expect(onAllChange).toHaveBeenCalledOnce(); + expect(onAllChange).toHaveBeenCalledWith(false); + }); + + it('shows aggregate Σ + total when isAll is true and no iterationStatuses', () => { + renderPill({ isAll: true, total: 8 }); + + expect(screen.getByText('Σ')).toBeInTheDocument(); + expect(screen.getByText('8')).toBeInTheDocument(); + }); + + it('shows completed and failed counts when isAll is true and iterationStatuses provided', () => { + renderPill({ + isAll: true, + iterationStatuses: new Map([ + [0, 'Completed'], + [1, 'Completed'], + [2, 'Failed'], + [3, 'Completed'], + ]), + }); + + expect(screen.getByText(/✓ 3/)).toBeInTheDocument(); + expect(screen.getByText(/✗ 1/)).toBeInTheDocument(); + }); + + it('hides nav buttons when isAll is true', () => { + renderPill({ isAll: true }); + + expect(screen.queryByRole('button', { name: 'Previous iteration' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Next iteration' })).not.toBeInTheDocument(); + }); +}); + +// ─── Inline edit ────────────────────────────────────────────────────────────── + +describe('inline edit', () => { + it('opens input when fraction is clicked', async () => { + const user = userEvent.setup(); + renderPill({ activeIndex: 1, total: 5 }); + + await user.click(screen.getByTitle('Click to jump to a specific iteration')); + + expect(screen.getByRole('spinbutton')).toBeInTheDocument(); + }); + + it('commits on Enter and calls onActiveIndexChange with the correct 0-based index', async () => { + const user = userEvent.setup(); + const { onActiveIndexChange } = renderPill({ activeIndex: 1, total: 5 }); + + await user.click(screen.getByTitle('Click to jump to a specific iteration')); + const input = screen.getByRole('spinbutton'); + await user.clear(input); + await user.type(input, '4'); + await user.keyboard('{Enter}'); + + expect(onActiveIndexChange).toHaveBeenCalledWith(3); + }); + + it('cancels on Escape without firing onActiveIndexChange', async () => { + const user = userEvent.setup(); + const { onActiveIndexChange } = renderPill({ activeIndex: 1, total: 5 }); + + await user.click(screen.getByTitle('Click to jump to a specific iteration')); + const input = screen.getByRole('spinbutton'); + await user.clear(input); + await user.type(input, '4'); + await user.keyboard('{Escape}'); + + expect(onActiveIndexChange).not.toHaveBeenCalled(); + expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument(); + }); + + it('clamps values above total to total', async () => { + const user = userEvent.setup(); + const { onActiveIndexChange } = renderPill({ activeIndex: 0, total: 5 }); + + await user.click(screen.getByTitle('Click to jump to a specific iteration')); + const input = screen.getByRole('spinbutton'); + await user.clear(input); + await user.type(input, '99'); + await user.keyboard('{Enter}'); + + expect(onActiveIndexChange).toHaveBeenCalledWith(4); + }); + + it('clamps values below 1 to 1 (index 0)', async () => { + const user = userEvent.setup(); + const { onActiveIndexChange } = renderPill({ activeIndex: 2, total: 5 }); + + await user.click(screen.getByTitle('Click to jump to a specific iteration')); + const input = screen.getByRole('spinbutton'); + await user.clear(input); + await user.type(input, '-5'); + await user.keyboard('{Enter}'); + + expect(onActiveIndexChange).toHaveBeenCalledWith(0); + }); + + it('does not open input when disabled', () => { + renderPill({ disabled: true }); + + const fraction = screen.queryByTitle('Click to jump to a specific iteration'); + expect(fraction).not.toBeInTheDocument(); + }); + + it('does not open input when isAll is active', () => { + renderPill({ isAll: true }); + + // In All mode the fraction span is replaced by aggregate content, no edit title + expect(screen.queryByTitle('Click to jump to a specific iteration')).not.toBeInTheDocument(); + }); +}); + +// ─── Jump to failed ─────────────────────────────────────────────────────────── + +describe('jump to failed', () => { + const failedStatuses = new Map([ + [0, 'Completed'], + [1, 'Failed'], + [2, 'Completed'], + ]); + + it('renders jump-to-failed button when a failed iteration exists', () => { + renderPill({ iterationStatuses: failedStatuses }); + + expect( + screen.getByRole('button', { name: 'Jump to first failed iteration' }) + ).toBeInTheDocument(); + }); + + it('calls onActiveIndexChange with the first failed index on click', async () => { + const user = userEvent.setup(); + const { onActiveIndexChange } = renderPill({ iterationStatuses: failedStatuses }); + + await user.click(screen.getByRole('button', { name: 'Jump to first failed iteration' })); + + expect(onActiveIndexChange).toHaveBeenCalledWith(1); + }); + + it('hides jump-to-failed when isAll is true', () => { + renderPill({ iterationStatuses: failedStatuses, isAll: true }); + + expect( + screen.queryByRole('button', { name: 'Jump to first failed iteration' }) + ).not.toBeInTheDocument(); + }); + + it('hides jump-to-failed when overallStatus is Failed', () => { + renderPill({ iterationStatuses: failedStatuses, overallStatus: 'Failed' }); + + expect( + screen.queryByRole('button', { name: 'Jump to first failed iteration' }) + ).not.toBeInTheDocument(); + }); + + it('hides jump-to-failed when no iterations have failed', () => { + renderPill({ + iterationStatuses: new Map([ + [0, 'Completed'], + [1, 'Completed'], + ]), + }); + + expect( + screen.queryByRole('button', { name: 'Jump to first failed iteration' }) + ).not.toBeInTheDocument(); + }); + + it('hides jump-to-failed when disabled', () => { + renderPill({ iterationStatuses: failedStatuses, disabled: true }); + + expect( + screen.queryByRole('button', { name: 'Jump to first failed iteration' }) + ).not.toBeInTheDocument(); + }); +}); + +// ─── Compact tier ───────────────────────────────────────────────────────────── + +describe('compact tier', () => { + it('does not render prev/next buttons', () => { + renderPill({ activeIndex: 2, total: 5 }, 'compact'); + + expect(screen.queryByRole('button', { name: 'Previous iteration' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Next iteration' })).not.toBeInTheDocument(); + }); + + it('still renders the All toggle', () => { + renderPill({}, 'compact'); + + expect( + screen.getByRole('button', { name: 'Show aggregate across all iterations' }) + ).toBeInTheDocument(); + }); +}); + +// ─── Minimal tier ───────────────────────────────────────────────────────────── + +describe('minimal tier', () => { + it('shows index / total without nav buttons', () => { + renderPill({ activeIndex: 2, total: 6 }, 'minimal'); + + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('6')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Previous iteration' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Next iteration' })).not.toBeInTheDocument(); + }); + + it('shows Σ + total when isAll and no iterationStatuses', () => { + renderPill({ isAll: true, total: 10 }, 'minimal'); + + expect(screen.getByText('Σ')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); + }); + + it('shows completed and failed counts when isAll and iterationStatuses provided', () => { + renderPill( + { + isAll: true, + iterationStatuses: new Map([ + [0, 'Completed'], + [1, 'Failed'], + ]), + }, + 'minimal' + ); + + expect(screen.getByText(/✓1/)).toBeInTheDocument(); + expect(screen.getByText(/✗1/)).toBeInTheDocument(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeExecutionCount.tsx b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeExecutionCount.tsx new file mode 100644 index 000000000..28c1f74ce --- /dev/null +++ b/packages/apollo-react/src/canvas/components/LoopNode/LoopNodeExecutionCount.tsx @@ -0,0 +1,355 @@ +import { cn } from '@uipath/apollo-wind'; +import { useRef, useState } from 'react'; +import { useSafeLingui } from '../../../i18n'; +import type { ElementStatusValues } from '../../types/execution'; +import { CanvasIcon } from '../../utils/icon-registry'; +import type { LoopNodeExecutionCountState } from './LoopNode.types'; + +export const IterationStatus = { + Completed: 'Completed', + Failed: 'Failed', + InProgress: 'InProgress', + Paused: 'Paused', + Cancelled: 'Cancelled', +} as const; + +export type IterationStatusValue = (typeof IterationStatus)[keyof typeof IterationStatus]; + +function stopEvent(e: React.SyntheticEvent) { + e.stopPropagation(); +} + +export function getIterationStatusColor(status: string | undefined): string { + switch (status) { + case IterationStatus.Completed: + return '#22c55e'; + case IterationStatus.Failed: + return '#ef4444'; + case IterationStatus.InProgress: + return '#f59e0b'; + case IterationStatus.Paused: + return '#a855f7'; + case IterationStatus.Cancelled: + return '#94a3b8'; + default: + return 'currentColor'; + } +} + +export function LoopNodeExecutionCount({ + state, + size = 'full', +}: { + state: LoopNodeExecutionCountState; + size?: 'full' | 'compact' | 'minimal'; +}) { + const { + activeIndex, + total, + onActiveIndexChange, + disabled, + isAll, + onAllChange, + iterationStatuses, + overallStatus, + } = state; + + const { _ } = useSafeLingui(); + const [isEditing, setIsEditing] = useState(false); + const [inputValue, setInputValue] = useState(''); + const inputRef = useRef(null); + + const canInteract = !disabled && typeof onActiveIndexChange === 'function'; + const visibleIndex = activeIndex + 1; + const clampToRange = (v: number) => Math.max(1, Math.min(total, v)); + + const currentStatus = iterationStatuses?.get(activeIndex); + const firstFailedIndex = iterationStatuses + ? [...iterationStatuses.entries()].find(([, s]) => s === IterationStatus.Failed)?.[0] + : undefined; + const completedCount = iterationStatuses + ? [...iterationStatuses.values()].filter((s) => s === IterationStatus.Completed).length + : undefined; + const failedCount = iterationStatuses + ? [...iterationStatuses.values()].filter((s) => s === IterationStatus.Failed).length + : 0; + + const handlePrev = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canInteract && !isAll && activeIndex > 0) onActiveIndexChange?.(activeIndex - 1); + }; + + const handleNext = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canInteract && !isAll && activeIndex < total - 1) onActiveIndexChange?.(activeIndex + 1); + }; + + const toggleAll = (e: React.MouseEvent) => { + e.stopPropagation(); + onAllChange(!isAll); + }; + + const handleJumpToFailed = (e: React.MouseEvent) => { + e.stopPropagation(); + if (firstFailedIndex !== undefined) onActiveIndexChange?.(firstFailedIndex); + }; + + const startEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!canInteract || isAll || isEditing) return; + setInputValue(String(visibleIndex)); + setIsEditing(true); + requestAnimationFrame(() => inputRef.current?.select()); + }; + + const commitEdit = () => { + const parsed = parseInt(inputValue, 10); + if (!Number.isNaN(parsed)) onActiveIndexChange?.(clampToRange(parsed) - 1); + setIsEditing(false); + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + if (e.key === 'Enter') commitEdit(); + if (e.key === 'Escape') setIsEditing(false); + }; + + const canGoPrev = canInteract && !isAll && activeIndex > 0; + const canGoNext = canInteract && !isAll && activeIndex < total - 1; + + // Minimal tier — read-only count chip + if (size === 'minimal') { + return ( +
+
+ {isAll ? ( + completedCount !== undefined ? ( + <> + + ✓{completedCount} + + {failedCount > 0 && ( + ✗{failedCount} + )} + + ) : ( + <> + Σ + {total} + + ) + ) : ( + <> + {currentStatus && ( + + )} + {visibleIndex} + / + {total} + + )} +
+
+ ); + } + + // Full and compact tiers — unified segmented pill + return ( +
+ {/* Single unified pill */} +
+ {/* Left segment — All toggle */} + + + {/* Divider */} +
+ + {/* Right segment — aggregate or navigation */} + {isAll ? ( + + ) : ( +
+ {/* Prev — hidden in compact */} + {size === 'full' && ( + + )} + + {/* Editable fraction with status dot */} + + {isEditing ? ( + <> + setInputValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={handleInputKeyDown} + onPointerDown={stopEvent} + className="w-7 appearance-none bg-transparent text-center text-[11px] font-semibold leading-none outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none border-b border-foreground-accent" + /> + / + {total} + + ) : ( + <> + {currentStatus && ( + + )} + {visibleIndex} + / + {total} + + )} + + + {/* Next — hidden in compact */} + {size === 'full' && ( + + )} +
+ )} +
+ + {/* Jump-to-failed shortcut — hidden when loop is globally Failed */} + {firstFailedIndex !== undefined && + !isAll && + canInteract && + overallStatus !== ('Failed' as ElementStatusValues) && ( + + )} +
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/LoopNode/index.ts b/packages/apollo-react/src/canvas/components/LoopNode/index.ts index cc5ed83ef..b43640b83 100644 --- a/packages/apollo-react/src/canvas/components/LoopNode/index.ts +++ b/packages/apollo-react/src/canvas/components/LoopNode/index.ts @@ -1,3 +1,4 @@ +export * from './LoopNodeExecutionCount'; export * from './LoopCanvasNode'; export * from './LoopNode'; export * from './LoopNode.helpers';